Files
hermes-agent/agent/turn_finalizer.py

682 lines
30 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``.
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 typing import Any, Callable, List, Optional, Tuple
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
# 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 = {".", "!", "?", "。", "!", "?", "`", ")"}
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
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 (#29747). 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 _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]:
"""Iteration-budget exhaustion. Returns ``(final_response, _turn_exit_reason,
preserved_verification_fallback)``."""
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"}
)
preserved_verification_fallback = False
if final_response is None and budget_fallback_eligible:
_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.
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
preserved_verification_fallback = True
else:
# _handle_max_iterations makes one extra toolless request for a summary.
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)
if budget_exhausted:
# 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.
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
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
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 _close_transcript_tail(agent, messages, final_response, interrupted, failed):
"""Shape the transcript tail before the durable snapshot; returns the (possibly
stream-recovered) ``final_response``."""
# 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)
# 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 ""
_streamed = _streamed.strip() if isinstance(_streamed, str) else ""
_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":
# Append 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))
)
):
# 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)
return final_response
def _micro_compact_after_turn(agent, messages, final_response, logger) -> 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` + 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
# No checkpoint hook, so 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)
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")
)
_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,
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",
)
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):
"""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."""
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)
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 == "" 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 _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),
)
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 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 = _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,
)
completed = (
final_response is not None
and not failed
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] = []
# ``user_message`` may be a multimodal list of parts; the trajectory format wants
# a plain 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.
try:
final_response = _close_transcript_tail(agent, messages, final_response, interrupted, failed)
if not interrupted and not failed:
_micro_compact_after_turn(agent, messages, final_response, logger)
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
_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:
# 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.
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,
):
if isinstance(_hook_result, str) and _hook_result:
_pre_transform_response = final_response
final_response = _hook_result
_response_transformed = True
break
# Plugin hook: post_llm_call (e.g. sync conversation data to an external
# memory system).
_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,
)
# 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.
_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: ``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)
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,
"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
if interrupted and agent._interrupt_message:
result["interrupt_message"] = agent._interrupt_message
agent.clear_interrupt()
# Clear stream callback so it doesn't leak into future calls.
agent._stream_callback = None
# Skill trigger is checked 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.
_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