mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
* fix(desktop): preserve interim assistant text wiped at message.complete
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
* fix: prefix-match interim streamed content to avoid benign duplicate bubbles
_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.
Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().
* test(desktop): add partial-stream-then-nudge dedup edge case
Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.
Acceptance protocol #2 — covers all three dedup edges:
1. interim == final (existing)
2. interim = strict prefix of final (existing)
3. partial-stream-then-nudge (this commit)
---------
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
628 lines
30 KiB
Python
628 lines
30 KiB
Python
"""Post-loop turn finalization for ``run_conversation``.
|
||
|
||
Extracted from ``agent/conversation_loop.py`` as part of the god-file
|
||
decomposition campaign (``~/.hermes/plans/god-file-decomposition.md``, Phase 1
|
||
step 4 — the post-loop ``TurnFinalizer`` seam). ``run_conversation``'s tail
|
||
(everything after the main tool-calling ``while`` loop) is lifted here verbatim:
|
||
budget-exhaustion summary, trajectory save, session persist, turn diagnostics,
|
||
response transforms, result-dict assembly, steer drain, and the memory/skill
|
||
review trigger.
|
||
|
||
Behavior-neutral: the body is moved unchanged. All ``agent.*`` side effects fire
|
||
exactly as before; only the post-loop *locals* are passed in as keyword args, and
|
||
the assembled ``result`` dict is returned to ``run_conversation`` which returns it
|
||
to the caller. The function is synchronous with a single return — mirroring the
|
||
region it replaces (no awaits, no early returns).
|
||
|
||
Module ``logger`` is imported lazily inside the body (``from
|
||
agent.conversation_loop import logger``) so this module never imports
|
||
``agent.conversation_loop`` at import time -> no import cycle, and the log records
|
||
keep the exact logger name (``"agent.conversation_loop"``).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||
from agent.message_content import flatten_message_text
|
||
|
||
|
||
def _is_pure_tool_call_tail(msg: dict) -> bool:
|
||
"""An assistant row with ``tool_calls`` but no visible text content of its own.
|
||
|
||
Such a row satisfies the role check (``tail role == "assistant"``) while
|
||
carrying none of the delivered answer — see the #43849/#44100 invariant
|
||
block in :func:`finalize_turn`. Uses :func:`flatten_message_text` so that
|
||
multimodal (list-type) content is evaluated by its text parts, not just
|
||
its type.
|
||
"""
|
||
if not msg.get("tool_calls"):
|
||
return False
|
||
return not flatten_message_text(msg.get("content")).strip()
|
||
|
||
|
||
# Verification continuation scaffolding flags: verify-on-stop / pre_verify
|
||
# inject a synthetic user nudge to keep the agent going one more turn.
|
||
# These nudges must be stripped from returned/live history to avoid
|
||
# role-alternation breaks and poisoning the resumed transcript. The
|
||
# assistant response is real content and is not flagged. (#65919 §7)
|
||
_VERIFICATION_CONTINUATION_FLAGS = (
|
||
"_verification_stop_synthetic",
|
||
"_pre_verify_synthetic",
|
||
)
|
||
|
||
|
||
def _drop_verification_continuation_scaffolding(messages) -> None:
|
||
"""Remove verification-continuation nudge messages from *messages* in place.
|
||
|
||
Only the synthetic nudges carry these flags, so this strips just the
|
||
nudges while preserving the real attempted-final-answer that was
|
||
persisted to state.db.
|
||
"""
|
||
messages[:] = [
|
||
m for m in messages
|
||
if not (isinstance(m, dict) and any(m.get(f) for f in _VERIFICATION_CONTINUATION_FLAGS))
|
||
]
|
||
|
||
|
||
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.
|
||
|
||
Lifted verbatim from ``run_conversation`` (the region after the main agent
|
||
loop). See module docstring.
|
||
"""
|
||
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/continuation gate deliberately withheld a composed
|
||
# answer, then consumed the remaining budget before producing a newer
|
||
# one. Preserve that exact answer instead of replacing it with another
|
||
# fallible model call. The explicit pending value is the provenance
|
||
# guard: unrelated error/recovery exits can never enter this branch.
|
||
final_response = _pending_verification_response
|
||
# Mark the turn as previewed only when the reused candidate was
|
||
# actually streamed to the user as interim content. (#65919 review:
|
||
# response-loss blocker)
|
||
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 — ask the model for a summary via one extra
|
||
# API call with tools stripped. _handle_max_iterations injects a
|
||
# user message and makes a single toolless request.
|
||
_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:
|
||
# 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).
|
||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||
if _kanban_task:
|
||
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}/{agent.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": agent.max_iterations,
|
||
},
|
||
)
|
||
logger.info(
|
||
"recorded budget-exhausted failure for task %s (%d/%d)",
|
||
_kanban_task, api_call_count, agent.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,
|
||
)
|
||
|
||
# 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
|
||
)
|
||
)
|
||
|
||
# Post-loop cleanup must never lose the response. Trajectory save,
|
||
# resource teardown, and session persistence all touch fallible
|
||
# surfaces — file I/O / JSON serialization (_save_trajectory), remote
|
||
# VM/browser teardown over the network (_cleanup_task_resources), and
|
||
# SQLite writes (_persist_session). A raise from any of them used to
|
||
# propagate straight out of run_conversation, discarding the partial
|
||
# final_response the caller is waiting for (subprocess wrappers saw an
|
||
# empty stdout with no traceback — #8049). Each step is now guarded
|
||
# independently so one failure can't skip the others, and any errors
|
||
# are surfaced on the result dict via ``cleanup_errors`` rather than
|
||
# killing the turn.
|
||
_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 session to both JSON log and SQLite only after private retry
|
||
# scaffolding has been removed. Otherwise a later user "continue" turn
|
||
# can replay assistant("(empty)") / recovery nudges and fall into the
|
||
# same empty-response loop again.
|
||
try:
|
||
agent._drop_trailing_empty_response_scaffolding(messages)
|
||
|
||
# Drop verification-continuation nudges (synthetic user messages)
|
||
# from the live history before the tail-assistant check — only the
|
||
# nudges need stripping; the assistant candidate persists in
|
||
# state.db. (#65919 §7)
|
||
_drop_verification_continuation_scaffolding(messages)
|
||
|
||
# When the turn was interrupted and the last message is a tool
|
||
# result, append a synthetic assistant message to close the
|
||
# tool-call sequence. Without this, the session persists a
|
||
# ``tool → user`` alternation that strict providers (Gemini,
|
||
# Claude) reject, causing them to hallucinate a continuation of
|
||
# the user's message on the next turn (#48879).
|
||
#
|
||
# ``_drop_trailing_empty_response_scaffolding`` only rewinds the
|
||
# tool tail when an empty-response scaffolding flag is present; a
|
||
# clean ``/stop`` interrupt after a successful tool sets no such
|
||
# flag, so the tool result survives as the tail and we close it
|
||
# here instead. On an interrupt ``final_response`` is typically
|
||
# empty, so fall back to an explicit placeholder rather than
|
||
# persisting an empty-content assistant turn.
|
||
if interrupted:
|
||
from agent.message_sanitization import close_interrupted_tool_sequence
|
||
close_interrupted_tool_sequence(messages, final_response)
|
||
|
||
# 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)
|
||
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).
|
||
messages.append({"role": "assistant", "content": final_response})
|
||
elif isinstance(_tail, dict) and _tail.get("content") != final_response and _is_pure_tool_call_tail(_tail):
|
||
# The tail IS an assistant row, but a *pure tool-call turn*:
|
||
# tool_calls with no text of its own. The role check alone
|
||
# leaves the #43849/#44100 invariant unmet — the user saw a
|
||
# response that never reached the transcript, and the next turn
|
||
# replays the user backlog and re-answers it (the very symptom
|
||
# this block was added for). Fill that row's empty content
|
||
# instead of appending, so the durable turn ends with the answer
|
||
# without disturbing the tool-call structure or creating an
|
||
# assistant→assistant pair.
|
||
#
|
||
# The ``content != final_response`` guard prevents filling when
|
||
# the tail already carries the final response text (verification
|
||
# candidate collapse — the provisional answer was persisted and
|
||
# reused as the terminal response, #65919 §7).
|
||
_tail["content"] = final_response
|
||
# The row may have already been flushed to SQLite by the
|
||
# incremental tool-call persist (conversation_loop.py:4990),
|
||
# which stamps ``_DB_PERSISTED_MARKER`` so subsequent flushes
|
||
# skip it. Pop the marker so the next ``_persist_session``
|
||
# re-writes the filled content to the durable store —
|
||
# otherwise ``/resume`` reloads ``content=""`` and the bug
|
||
# resurfaces cross-session.
|
||
_tail.pop("_db_persisted", None)
|
||
|
||
# The model has completed its request, so replace API-local
|
||
# voice/model/skill guidance with the clean user input before writing the
|
||
# final durable snapshot and returning the continuation history. 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)
|
||
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)
|
||
|
||
# ── Turn-exit diagnostic log ─────────────────────────────────────
|
||
# Always logged at INFO so agent.log captures WHY every turn ended.
|
||
# When the last message is a tool result (agent was mid-work), log
|
||
# at WARNING — this is the "just stops" scenario users report.
|
||
_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 one or more ``write_file`` / ``patch`` calls failed during this
|
||
# turn and were never superseded by a successful write to the same
|
||
# path, append an advisory footer to the assistant response. 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.
|
||
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.
|
||
# When a turn ends abnormally after substantive work — empty content
|
||
# after retries, a partial/truncated stream, a still-pending tool
|
||
# result, or an iteration/budget limit — the user otherwise gets a
|
||
# blank or fragmentary response box with no consolidated reason why
|
||
# the agent stopped (#34452). Surface a single user-visible
|
||
# explanation derived from ``_turn_exit_reason``, mirroring the
|
||
# file-mutation verifier footer pattern above.
|
||
#
|
||
# Gate carefully so healthy turns stay quiet:
|
||
# - ``text_response(...)`` exits never produce an explanation
|
||
# (handled inside the formatter), so a terse ``Done.`` is silent.
|
||
# - We only ACT when there is no genuinely usable reply this turn:
|
||
# an empty response, the "(empty)" terminal sentinel, or a
|
||
# suspiciously short partial fragment with no terminating
|
||
# punctuation (e.g. "The"). A real short answer keeps its text.
|
||
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 that is not a normal text_response exit
|
||
# and lacks sentence-ending punctuation is treated as a
|
||
# truncated partial (the "The" case from #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
|
||
)
|
||
if _explanation:
|
||
if _is_empty_terminal:
|
||
# Replace the bare "(empty)"/blank sentinel with
|
||
# the actionable explanation.
|
||
final_response = _explanation
|
||
else:
|
||
# Keep the partial fragment, append the reason so
|
||
# the user sees both what arrived and 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
|
||
|
||
# Plugin hook: transform_llm_output
|
||
# Fired once per turn after the tool-calling loop completes.
|
||
# Plugins can transform the LLM's output text before it's returned.
|
||
# First hook to return a string wins; None/empty return leaves text unchanged.
|
||
if final_response and not interrupted:
|
||
try:
|
||
from hermes_cli.plugins 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:
|
||
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-calling loop completes.
|
||
# Plugins can use this to persist conversation data (e.g. sync
|
||
# to an external memory system).
|
||
if final_response and not interrupted:
|
||
try:
|
||
from hermes_cli.plugins 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)
|
||
|
||
# Extract reasoning from the CURRENT turn only. Walk backwards
|
||
# but stop at the user message that started this turn — anything
|
||
# earlier is from a prior turn and must not leak into the reasoning
|
||
# box (confusing stale display; #17055). Within the current turn
|
||
# we still want the *most recent* non-empty reasoning: many
|
||
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
|
||
# reasoning on the tool-call step and leave the final-answer step
|
||
# with reasoning=None, so picking only the last assistant would
|
||
# silently drop legitimate same-turn reasoning.
|
||
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
|
||
|
||
# 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,
|
||
"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()
|
||
# Surface any post-loop cleanup failures so the caller can distinguish a
|
||
# clean turn from one whose trajectory/session/resource teardown raised
|
||
# (the response is still returned either way — #8049).
|
||
if _cleanup_errors:
|
||
result["cleanup_errors"] = _cleanup_errors
|
||
# If a /steer landed after the final assistant turn (no more tool
|
||
# batches to drain into), hand it back to the caller so it can be
|
||
# delivered as the next user turn instead of being silently 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 the response is delivered
|
||
# so it never competes with the user's task for model attention.
|
||
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
|
||
try:
|
||
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
|
||
|
||
# Note: Memory provider on_session_end() + shutdown_all() are NOT
|
||
# called here — run_conversation() is called once per user message in
|
||
# multi-turn sessions. Shutting down after every turn would kill the
|
||
# provider before the second message. Actual session-end cleanup is
|
||
# handled by the CLI (atexit / /reset) and gateway (session expiry /
|
||
# _reset_session).
|
||
|
||
# Plugin hook: on_session_end
|
||
# Fired at the very end of every run_conversation call.
|
||
# Plugins can use this for cleanup, flushing buffers, etc.
|
||
try:
|
||
from hermes_cli.plugins 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,
|
||
interrupted=interrupted,
|
||
model=agent.model,
|
||
platform=getattr(agent, "platform", None) or "",
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("on_session_end hook failed: %s", exc)
|
||
|
||
return result
|