Files
hermes-agent/agent/turn_iteration_prep.py
crazyief edd14100f9 fix(kanban): sync terminal-knowledge copies that kanban_request_review callers hit
agent/kanban_stop.py now treats kanban_request_review as a terminal board
call, but two sibling copies of the same terminal-knowledge were not
updated and still steer workers/runs toward kanban_complete only, which
deterministically fails after request_review already ended the run:

- hermes_cli/kanban_db_dispatch.py: _PROTOCOL_VIOLATION_ERROR (the
  corrective sentence surfaced to retry workers) now names all three
  terminal calls and points at kanban_request_review for review-ready work.
- agent/turn_iteration_prep.py: the kanban-worker budget-warning tail no
  longer nudges a near-budget worker exclusively toward kanban_complete.

Co-authored-by: ximontk-review <8566250+crazyief@users.noreply.github.com>

Tests: tests/agent/test_kanban_stop.py 3 passed (main, without #105645).
2026-09-18 09:21:42 -07:00

520 lines
25 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.

"""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
from agent.turn_context_compaction import _reanchor
logger = logging.getLogger("agent.conversation_loop")
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:
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
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:
_issuer = interrupt_issuer(agent)
_turn_exit_reason = (
f"interrupted_during_api_call({_issuer})" if _issuer else "interrupted_during_api_call"
)
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 output budget per retry: 2×, 4×, 8×, 16× base, capped at 32 768, via
# _ephemeral_max_output_tokens. Keep a larger original provider/model
# default as the floor so retries never downshift.
_boost = (agent.max_tokens or 4096) * (2 ** length_continue_retries)
_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)
if _requested_cap is not None:
_boost = max(_boost, _requested_cap)
_boost_cap = max(32768, _requested_cap or 0)
agent._ephemeral_max_output_tokens = min(_boost, _boost_cap)
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")