Files
hermes-agent/agent/turn_iteration_prep.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

543 lines
26 KiB
Python

"""Outer-iteration bookkeeping for the conversation turn loop, in call order:
``begin_iteration`` (pending redirect, interrupt / review-budget / iteration-budget exits),
``prepare_iteration`` (``agent:step`` callback, skill-nudge counter, pre-API ``/steer`` drain as a
standalone user row after the newest tool result, run-budget wrap-up notice, tool_call
argument sanitization, interrupt-scaffold ghost-row drop, role-alternation repair),
``announce_api_call`` (verbose summary / quiet spinner) and, after the retry loop,
``apply_retry_restarts`` (consumes the ``TurnRetryState`` restart flags). Nothing here
imports ``agent.conversation_loop`` at module level (cycle)."""
from __future__ import annotations
import logging
import random
import sys
from contextlib import suppress
from dataclasses import dataclass
from typing import Any, Dict
from agent.display import KawaiiSpinner
from agent.interrupt_control import interrupt_issuer, interrupted_during_api_call_reason
from agent.turn_context_compaction import _reanchor
from agent.turn_truncation import boosted_output_cap
logger = logging.getLogger("agent.conversation_loop")
def _anchors_current_turn(messages: Any, idx: Any, user_message: Any) -> bool:
"""True when ``messages[idx]`` is this turn's user row (verbatim, or its user-originated view)."""
if not isinstance(idx, int) or not 0 <= idx < len(messages):
return False
msg = messages[idx]
if not (isinstance(msg, dict) and msg.get("role") == "user"):
return False
if msg.get("content") == user_message:
return True
from agent.context_compressor import user_originated_turn_view
view = user_originated_turn_view(msg)
return view is not None and view.get("content") == user_message
ITERATION_BUDGET_WARNING_TEMPLATE = (
"[SYSTEM NOTICE — iteration budget checkpoint] You have used {used} of {maximum} "
"iterations. Checkpoint durable progress now, then continue the task; do not stop "
"solely because of this warning."
)
def _maybe_inject_iteration_budget_warning(agent: Any, messages: Any) -> bool:
"""Append the opt-in one-shot warning to the newest tool result."""
import os
from agent.delegation_context import is_dispatcher_owned_worker_context
# Cancellation results still need persistence, but must not urge more work.
if getattr(agent, "_interrupt_requested", False):
return False
ratio = getattr(agent, "budget_warning_ratio", None)
kanban_worker = (
bool(os.environ.get("HERMES_KANBAN_TASK"))
and is_dispatcher_owned_worker_context()
and "kanban_complete" in getattr(agent, "valid_tool_names", ())
)
if ratio is None and kanban_worker:
ratio = 0.9
budget = getattr(agent, "iteration_budget", None)
if (
ratio is None
or budget is None
or budget.max_total <= 1
or budget.max_total >= sys.maxsize
or getattr(agent, "_iteration_budget_warning_injected", False)
or budget.used < min(ratio * budget.max_total, budget.max_total - 1)
):
return False
notice = ITERATION_BUDGET_WARNING_TEMPLATE.format(
used=budget.used, maximum=budget.max_total
)
if kanban_worker:
notice += (
" While tools are still available, call kanban_complete only if all task "
"requirements are verified, or kanban_request_review if it is ready for "
"review; otherwise persist a kanban_comment handoff and "
"continue. A diff or commit alone is not completion evidence."
)
# Only the current tool-result tail is mutable; an older turn may already be cached.
from agent.context_compressor import _DB_PERSISTED_MARKER
if (not messages or messages[-1].get("role") != "tool"
or messages[-1].get(_DB_PERSISTED_MARKER)):
return False
message = messages[-1]
content = message.get("content", "")
if isinstance(content, str):
message["content"] = content + f"\n\n{notice}"
elif isinstance(content, list) or content is None:
message["content"] = [*(content or []), {"type": "text", "text": notice}]
else:
return False
agent._iteration_budget_warning_injected = True
return True
@dataclass
class IterationPrep:
"""Always ``action == "fallthrough"``. ``messages`` is the (possibly filtered) transcript
and ``request_logger`` the per-request logger the caller keeps using."""
action: str
messages: Any
request_logger: Any
current_turn_user_idx: Any
def prepare_iteration(
agent: Any, *, messages: Any, api_call_count: Any, user_message: Any = None, current_turn_user_idx: Any = None,
) -> IterationPrep:
"""Prepare ``messages`` for this iteration in the original order. Every mutation here is
cache-safe by construction: steer text is appended as a new (not yet persisted) user row, the ghost-row
filter only drops hidden scaffold placeholders, and repair runs BEFORE the request build."""
from agent.conversation_loop import (
_INTERRUPT_SCAFFOLD_MARKER, _maybe_inject_run_budget_wrapup
)
# nous.anthropic_wire=auto: a wire switch decided from the previous response lands here,
# before this iteration's request is built and with nothing in flight.
if getattr(agent, "_nous_wire_pending", None):
from agent.nous_wire import apply_pending_wire_switch
apply_pending_wire_switch(agent)
# Fire step_callback for gateway hooks (agent:step event).
if agent.step_callback is not None:
try:
agent.step_callback(api_call_count, _previous_tool_round(messages))
except Exception as _step_err:
logger.debug("step_callback error (iteration %s): %s", api_call_count, _step_err)
# Tool-calling iterations for the skill nudge; resets whenever skill_manage is used.
if agent._skill_nudge_interval > 0 and "skill_manage" in agent.valid_tool_names:
agent._iters_since_skill += 1
# Nous agent keys live ~1 h and a single turn can run for hours: adopt the keepalive's fresh
# key before the one in hand expires (local JWT exp read; no network unless inside the skew)
# instead of letting this iteration's request 401. With many agents sharing the hour that
# 401 was a storm, and the pool benched the sole credential for all of them.
try:
agent._adopt_nous_key_before_expiry()
except Exception:
logger.debug("Nous key pre-expiry adoption failed", exc_info=True)
# Drain a /steer sent during the last API call so it lands THIS iteration. Delivered as a
# standalone user row after the newest tool result (never smeared onto the tool row: that
# row is already persisted append-only, so replay would diverge from the live request and
# break the prompt cache — same contract as apply_pending_steer_to_tool_results).
_pre_api_steer = agent._drain_pending_steer()
if _pre_api_steer:
_inject_steer_after_newest_tool_result(agent, messages, _pre_api_steer)
# One-shot run-budget wrap-up notice at 80% of agent.run_budget_seconds, appended to the
# newest tool result; off with no budget.
if getattr(agent, "run_budget_seconds", None):
_maybe_inject_run_budget_wrapup(agent, messages)
# Appended to the newest tool result; never a synthetic user/system row.
_maybe_inject_iteration_budget_warning(agent, messages)
request_logger = getattr(agent, "logger", None) or logger # same name as the origin module
# Per-agent validation cursor skips re-parsing tool_call args already validated.
# Identity-keyed; a rewritten list breaks the prefix match and forces a re-scan.
_sanitize_cursor = getattr(agent, "_sanitize_args_cursor", None)
if _sanitize_cursor is None:
_sanitize_cursor = {}
with suppress(Exception):
agent._sanitize_args_cursor = _sanitize_cursor
repaired_tool_calls = agent._sanitize_tool_call_arguments(
messages, logger=request_logger, session_id=agent.session_id, cursor=_sanitize_cursor
)
if repaired_tool_calls > 0:
# In-place arg repair may have popped _DB_PERSISTED_MARKER off stamped live dicts;
# force a full flush scan so the repaired rows are rewritten.
agent._db_flush_scan_prefix = None
request_logger.info(
"Sanitized %s corrupted tool_call arguments before request (session=%s)",
repaired_tool_calls,
agent.session_id or "-",
)
# Drop legacy hidden assistant placeholders carrying the raw interrupt scaffold
# before repair: replayed, the model echoes/self-replicates.
def _is_scaffold_ghost(msg: Dict[str, Any]) -> bool:
return (
msg.get("display_kind") == "hidden"
and msg.get("role") == "assistant"
and any(
isinstance(msg.get(k), str) and msg[k].strip() == _INTERRUPT_SCAFFOLD_MARKER
for k in ("content", "api_content")
)
)
messages = [msg for msg in messages if not _is_scaffold_ghost(msg)]
# Repair malformed role alternation (tool→user / user→user tails): providers
# return empty content on them and the empty-retry loop spins. The _with_cursor
# variant also recomputes the SessionDB flush cursor after compaction.
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
repaired_seq = repair_message_sequence_with_cursor(agent, messages)
if repaired_seq > 0:
request_logger.info(
"Repaired %s message-alternation violations before request (session=%s)",
repaired_seq,
agent.session_id or "-",
)
# The merge shrank the list, so the index recorded at turn start can point past this
# turn's user row: prefetch would inject into a historical row and index-settling hosts
# (hermes-webui) would write the current turn to the FRONT of the context. Re-anchor as
# the compression-restart path does (last verbatim row wins, never a historical copy);
# without the text the index cannot be re-derived and is left detectably stale.
if user_message is not None:
_reanchored_idx = _reanchor(agent, messages, user_message)
if _reanchored_idx != current_turn_user_idx:
request_logger.info(
"Re-anchored current_turn_user_idx %s -> %s after alternation repair (session=%s)",
current_turn_user_idx, _reanchored_idx, agent.session_id or "-",
)
current_turn_user_idx = _reanchored_idx
# Mid-turn compaction (post-tool gate, overflow restart, recovery) rebuilds ``messages`` without
# handing back a new index. A stale index splits the request's replay prefix inside this turn's
# tool rows: prefix canonicalization then drops the assistant tool_call whose result fell past the
# split, the orphaned result is sanitized away, and the model silently loses tool output that
# state.db still holds. A valid index always lands on this turn's user row; re-anchor otherwise.
if user_message is not None and not _anchors_current_turn(messages, current_turn_user_idx, user_message):
_reanchored_idx = _reanchor(agent, messages, user_message)
request_logger.info(
"Re-anchored stale current_turn_user_idx %s -> %s (session=%s)",
current_turn_user_idx, _reanchored_idx, agent.session_id or "-",
)
current_turn_user_idx = _reanchored_idx
return IterationPrep(
action="fallthrough", messages=messages, request_logger=request_logger,
current_turn_user_idx=current_turn_user_idx,
)
def _previous_tool_round(messages: Any) -> list:
"""The newest assistant tool_calls batch with each call's result, for ``agent:step``."""
for _idx, _m in enumerate(reversed(messages)):
if _m.get("role") == "assistant" and _m.get("tool_calls"):
_results_by_id = {}
for _tm in messages[len(messages) - _idx:]:
if _tm.get("role") != "tool":
break
_tcid = _tm.get("tool_call_id")
if _tcid:
_results_by_id[_tcid] = _tm.get("content", "")
return [
{
"name": tc["function"]["name"],
"result": _results_by_id.get(tc.get("id")),
"arguments": tc["function"].get("arguments"),
}
for tc in _m["tool_calls"]
if isinstance(tc, dict)
]
return []
def _inject_steer_after_newest_tool_result(agent: Any, messages: Any, steer_text: str) -> None:
"""Append the steer marker as a standalone user row after the newest tool message; with no
tool message, put the text back so the post-tool-execution drain delivers it later."""
for _si in range(len(messages) - 1, -1, -1):
_sm = messages[_si]
if isinstance(_sm, dict) and _sm.get("role") == "tool":
from agent.prompt_builder import steer_user_row
messages.insert(_si + 1, steer_user_row(steer_text))
logger.debug("Pre-API-call steer drain: appended user row after tool msg at index %d", _si)
return
from agent.agent_runtime_helpers import _requeue_pending_steer
_requeue_pending_steer(agent, steer_text)
@dataclass
class ApiCallAnnouncement:
"""Always ``action == "fallthrough"``; ``thinking_spinner`` is the started raw spinner or
None (TUI widget / streaming consumers / verbose mode)."""
action: str
thinking_spinner: Any
def announce_api_call(
agent: Any, *, messages: Any, api_messages: Any, api_call_count: Any, approx_tokens: Any,
total_chars: Any,
) -> ApiCallAnnouncement:
"""Print the request summary (verbose) or start the quiet-mode thinking indicator."""
thinking_spinner = None
if not agent.quiet_mode:
agent._vprint(f"\n{agent.log_prefix}🔄 Making API call #{api_call_count}/{agent.max_iterations}...")
agent._vprint(f"{agent.log_prefix} 📊 Request size: {len(api_messages)} messages, ~{approx_tokens:,} tokens (~{total_chars:,} chars)")
agent._vprint(f"{agent.log_prefix} 🔧 Available tools: {len(agent.tools) if agent.tools else 0}")
else:
# Animated thinking spinner in quiet mode
face = random.choice(KawaiiSpinner.get_thinking_faces())
verb = random.choice(KawaiiSpinner.get_thinking_verbs())
if agent.thinking_callback:
# CLI TUI mode: use prompt_toolkit widget instead of raw spinner
# (works in both streaming and non-streaming modes)
agent.thinking_callback(f"{face} {verb}...")
elif not agent._has_stream_consumers() and agent._should_start_quiet_spinner():
# Raw KawaiiSpinner only when no streaming consumers and the
# spinner output has a safe sink.
spinner_type = random.choice(['brain', 'sparkle', 'pulse', 'moon', 'star'])
thinking_spinner = KawaiiSpinner(f"{face} {verb}...", spinner_type=spinner_type, print_fn=agent._print_fn)
thinking_spinner.start()
# Log request details if verbose
if agent.verbose_logging:
logging.debug(f"API Request - Model: {agent.model}, Messages: {len(messages)}, Tools: {len(agent.tools) if agent.tools else 0}")
logging.debug(f"Last message role: {messages[-1]['role'] if messages else 'none'}")
logging.debug(f"Total message size: ~{approx_tokens:,} tokens")
return ApiCallAnnouncement(action="fallthrough", thinking_spinner=thinking_spinner)
@dataclass
class IterationStart:
"""``action``: ``"fallthrough"`` (run the iteration) or ``"break"`` (turn ends: interrupt,
review input budget or iteration budget exhausted — ``_turn_exit_reason`` set)."""
action: str
original_user_message: Any
api_call_count: Any
interrupted: Any
_turn_exit_reason: Any
def begin_iteration(
agent: Any, *, messages: Any, conversation_history: Any, original_user_message: Any,
api_call_count: Any, interrupted: Any, _turn_exit_reason: Any,
) -> IterationStart:
"""Iteration entry in the original order: apply a pending redirect, reset the checkpoint
dedup, then the interrupt / review-budget / iteration-budget exits. ``api_call_count`` is
incremented here (the grace call consumes its flag instead of the budget)."""
from agent.conversation_loop import (
_apply_active_turn_redirect, _review_input_budget_exhausted
)
def _verdict(action: str) -> IterationStart:
return IterationStart(
action=action, original_user_message=original_user_message,
api_call_count=api_call_count, interrupted=interrupted,
_turn_exit_reason=_turn_exit_reason,
)
_redirect_text = agent._drain_pending_redirect()
if _redirect_text:
_apply_active_turn_redirect(agent, messages, _redirect_text)
if isinstance(original_user_message, str):
original_user_message = (
f"{original_user_message}\n\n" f"User correction during the turn: {_redirect_text}"
)
agent._persist_session(messages, conversation_history)
# Reset per-turn checkpoint dedup so each iteration can take one snapshot.
agent._checkpoint_mgr.new_turn()
if agent._interrupt_requested:
interrupted = True
_issuer = interrupt_issuer(agent)
_turn_exit_reason = f"interrupted_by_system({_issuer})" if _issuer else "interrupted_by_user"
if not agent.quiet_mode:
agent._safe_print("\n⚡ Breaking out of tool loop due to interrupt...")
return _verdict("break")
# Aggregate input budget for detached auxiliary forks bounds the whole review, not
# each request; checked between iterations so the crossing request's writes landed.
if _review_input_budget_exhausted(agent):
_turn_exit_reason = "review_input_budget_exhausted"
if not agent.quiet_mode:
agent._safe_print(
f"\n⏹️ Review input budget exhausted "
f"({int(agent.session_input_tokens):,} tokens) — stopping "
f"the review tool loop before the next provider call.", diagnostic=True,
)
return _verdict("break")
api_call_count += 1
agent._api_call_count = api_call_count
agent._touch_activity(f"starting API call #{api_call_count}")
# Grace call: budget exhausted but the model gets one more call. Consume the
# flag so the loop exits after this iteration regardless of outcome.
if agent._budget_grace_call:
# Exhaustion retains one toolless grace call regardless of whether an opt-in
# checkpoint was emitted; the warning never extends the hard budget.
agent._budget_grace_call = False
elif not agent.iteration_budget.consume():
_turn_exit_reason = "budget_exhausted"
if not agent.quiet_mode:
agent._safe_print(f"\n⚠️ Iteration budget exhausted ({agent.iteration_budget.used}/{agent.iteration_budget.max_total} iterations used)", diagnostic=True)
return _verdict("break")
return _verdict("fallthrough")
@dataclass
class RetryRestartVerdict:
"""``action``: ``"fallthrough"`` (a response is ready — process it), ``"continue"``
(a restart flag re-issues the iteration: redirect / compressed / rebuilt-for-fallback /
length continuation) or ``"break"`` (turn ends: interrupted, non-actionable compaction
handoff, or every retry exhausted without a response)."""
action: str
current_turn_user_idx: Any
final_response: Any
retry_count: Any
restart_count: Any
api_call_count: Any
_preflight_compression_blocked: Any
_turn_exit_reason: Any
def apply_retry_restarts(
agent: Any, *, _retry: Any, response: Any, interrupted: Any, messages: Any,
conversation_history: Any, user_message: Any, api_kwargs: Any, current_turn_user_idx: Any,
final_response: Any, retry_count: Any, max_retries: Any, api_call_count: Any,
restart_count: Any, length_continue_retries: Any,
_preflight_compression_blocked: Any, _turn_exit_reason: Any,
) -> RetryRestartVerdict:
"""Consume the ``TurnRetryState`` restart flags after the retry loop, in the original
priority order. Refunds the iteration budget/count for restarts that produced no valid
assistant item; ``restart_with_rebuilt_messages`` is the single consumer that clears
``_preflight_compression_blocked`` so the fallback gets a fresh preflight (#84733).
The two refunding restart paths (redirect and rebuilt-for-fallback) are bounded by
``max_retries`` via ``restart_count`` (a per-turn accumulator) so a runaway
interrupt/redirect that keeps re-arming a restart flag cannot refund the budget
forever and hold the turn lease indefinitely."""
from agent.conversation_loop import (
_HANDOFF_SKIP_FINAL_RESPONSE, _should_skip_model_call_for_reference_handoff
)
def _verdict(action: str) -> RetryRestartVerdict:
return RetryRestartVerdict(
action=action, current_turn_user_idx=current_turn_user_idx,
final_response=final_response, retry_count=retry_count, restart_count=restart_count,
api_call_count=api_call_count,
_preflight_compression_blocked=_preflight_compression_blocked,
_turn_exit_reason=_turn_exit_reason,
)
if _retry.restart_with_redirected_messages:
restart_count += 1
if restart_count > max_retries:
# A redirect/interrupt keeps re-arming this flag: stop refunding the iteration
# budget and re-issuing the same logical iteration, or a runaway turn holds the
# turn lease indefinitely (redirect restarts previously had no bound).
_turn_exit_reason = "redirect_restart_limit_exceeded"
logger.warning(
"Redirected-message restart limit (%s) exceeded; ending turn instead of "
"refunding the iteration budget indefinitely.",
max_retries,
)
# The correction that tripped the cap was never applied; hand it back as the
# next user turn (result["pending_steer"]) instead of losing it to clear_interrupt().
_unapplied = agent._drain_pending_redirect()
if _unapplied:
agent.steer(_unapplied)
return _verdict("break")
# Cancelled request produced no valid assistant item: reuse the same logical
# iteration after the outer loop appends partial context + correction.
api_call_count -= 1
agent.iteration_budget.refund()
_retry.restart_with_redirected_messages = False
return _verdict("continue")
if interrupted:
_turn_exit_reason = interrupted_during_api_call_reason(agent)
return _verdict("break")
if _retry.restart_with_compressed_messages:
api_call_count -= 1
agent.iteration_budget.refund()
# Compression restarts count toward the retry limit so a compression that
# shrinks messages but not enough can't loop forever.
retry_count += 1
_retry.restart_with_compressed_messages = False
if _should_skip_model_call_for_reference_handoff(
# Compression rebuilt the list (tail messages are fresh compaction copies), so the
# pre-compression index of this turn's user message is stale. Re-anchor both index trackers: the
# api_content stamp below, the loop's injection site, and the flush's persist-override row
# (#48677) must all target the surviving dict, not a stale position. Exact-content match first
# so a todo-snapshot user message appended after the tail can't steal the anchor.
messages, user_message
):
logger.info(
"Skipping compressed-restart model call: reference-only "
"handoff would be the sole active user turn (#80622)"
)
if not final_response:
final_response = _HANDOFF_SKIP_FINAL_RESPONSE
_turn_exit_reason = "compaction_handoff_not_actionable"
return _verdict("break")
# In-loop compression rebuilt `messages`; re-anchor the current-turn index
# like the prologue, AFTER the handoff guard (it may re-append this turn's
# ask). A stale anchor injects prefetch into a historical row.
current_turn_user_idx = _reanchor(agent, messages, user_message)
return _verdict("continue")
if _retry.restart_with_rebuilt_messages:
restart_count += 1
if restart_count > max_retries:
# A stall/failure keeps re-escalating to the fallback chain: stop refunding the
# iteration budget and re-issuing, or a runaway turn holds the turn lease
# indefinitely (rebuilt restarts previously had no bound).
_turn_exit_reason = "rebuilt_restart_limit_exceeded"
logger.warning(
"Rebuilt-message restart limit (%s) exceeded; ending turn instead of "
"refunding the iteration budget indefinitely.",
max_retries,
)
return _verdict("break")
# A stall/failure escalated to the fallback chain: re-issue against the
# active fallback provider, refunding budget/count for the stalled attempt.
api_call_count -= 1
agent.iteration_budget.refund()
_retry.restart_with_rebuilt_messages = False
# Failover shrank the compressor window: clear the preflight block so
# preflight re-runs before the first fallback call (single consumer).
_preflight_compression_blocked = False
return _verdict("continue")
if _retry.restart_with_length_continuation:
# Boost the output budget per retry (shared ladder, see boosted_output_cap).
agent._ephemeral_max_output_tokens = boosted_output_cap(
agent, agent._requested_output_cap_from_api_kwargs(api_kwargs), length_continue_retries
)
return _verdict("continue")
# All retries may exhaust with `response` still None; break out cleanly.
if response is None:
_turn_exit_reason = "all_retries_exhausted_no_response"
agent._emit_diagnostic_status("❌ The model provider didn't answer after all retries. Send /retry, or switch models with /model.")
agent._persist_session(messages, conversation_history)
return _verdict("break")
return _verdict("fallthrough")