From 0eddeabb7667c460ba22ce50e7989526687b4c1c Mon Sep 17 00:00:00 2001 From: Stefan van Biljon Date: Wed, 22 Jul 2026 09:17:04 +0100 Subject: [PATCH 1/2] refactor(agent): port bounded max-turn continuation --- agent/auto_continue.py | 211 ++++++++++++ agent/conversation_loop.py | 4 + agent/message_sanitization.py | 28 ++ agent/turn_finalizer.py | 69 +++- .../assistant-ui/thread/user-message.tsx | 29 +- apps/desktop/src/lib/auto-continue.test.ts | 42 +++ apps/desktop/src/lib/auto-continue.ts | 55 +++ apps/desktop/src/types/hermes.ts | 5 + cli-config.yaml.example | 8 + cli.py | 18 +- gateway/slash_commands.py | 14 +- hermes_cli/config.py | 9 + hermes_state.py | 17 +- run_agent.py | 16 +- tests/agent/test_auto_continue_config.py | 123 +++++++ .../test_turn_finalizer_cleanup_guard.py | 18 +- ...est_turn_finalizer_iteration_limit_exit.py | 96 +++++- tests/cli/test_cli_retry.py | 45 +++ tests/gateway/test_retry_replacement.py | 39 +++ .../test_auto_continue_user_targeting.py | 38 +++ .../test_auto_continue_max_iterations.py | 319 ++++++++++++++++++ tests/test_tui_gateway_server.py | 70 ++++ tests/tui_gateway/test_protocol.py | 30 ++ tui_gateway/server.py | 17 +- 24 files changed, 1251 insertions(+), 69 deletions(-) create mode 100644 agent/auto_continue.py create mode 100644 apps/desktop/src/lib/auto-continue.test.ts create mode 100644 apps/desktop/src/lib/auto-continue.ts create mode 100644 tests/agent/test_auto_continue_config.py create mode 100644 tests/hermes_state/test_auto_continue_user_targeting.py create mode 100644 tests/run_agent/test_auto_continue_max_iterations.py diff --git a/agent/auto_continue.py b/agent/auto_continue.py new file mode 100644 index 0000000000000..1bba1efb4d3cb --- /dev/null +++ b/agent/auto_continue.py @@ -0,0 +1,211 @@ +"""Bounded fresh-turn continuation after max-iteration exhaustion. + +The continuation policy lives outside ``conversation_loop.run_conversation``. +That inner function owns exactly one turn and exactly one ``IterationBudget``; +when an eligible turn exhausts, its finalizer closes and persists the turn, then +this coordinator starts another turn through the normal turn-context prologue. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER = ( + "[Continuing after max-iteration exhaustion]" +) +DEFAULT_AUTO_CONTINUE_ON_MAX_ITERATIONS_PROMPT = ( + "Continue autonomously from the current state. Do not repeat completed work. " + "Stop and summarize if blocked, if approval is required, or before " + "destructive or externally visible actions." +) + + +@dataclass(frozen=True) +class AutoContinueConfig: + """Normalized, fail-closed auto-continuation policy.""" + + enabled: bool = False + max_auto_continues: int = 0 + prompt: str = DEFAULT_AUTO_CONTINUE_ON_MAX_ITERATIONS_PROMPT + + def can_continue(self, *, used: int) -> bool: + return ( + self.enabled + and self.max_auto_continues > 0 + and max(0, int(used)) < self.max_auto_continues + ) + + +def resolve_auto_continue_config(config: Any) -> AutoContinueConfig: + """Normalize the nested config without ever creating an unbounded policy.""" + if not isinstance(config, dict): + return AutoContinueConfig() + agent_config = config.get("agent") + if not isinstance(agent_config, dict): + return AutoContinueConfig() + raw = agent_config.get("auto_continue_on_max_iterations") + if raw is True: + raw = {"enabled": True} + elif raw is False or raw is None: + raw = {} + if not isinstance(raw, dict): + return AutoContinueConfig() + + enabled = raw.get("enabled") is True + raw_maximum = raw.get("max_auto_continues", 0) + try: + maximum = 0 if isinstance(raw_maximum, bool) else int(raw_maximum) + except (TypeError, ValueError, OverflowError): + maximum = 0 + maximum = max(0, maximum) + + raw_prompt = raw.get("prompt") + prompt = raw_prompt.strip() if isinstance(raw_prompt, str) else "" + if not prompt: + prompt = DEFAULT_AUTO_CONTINUE_ON_MAX_ITERATIONS_PROMPT + return AutoContinueConfig( + enabled=enabled, + max_auto_continues=maximum, + prompt=prompt, + ) + + +def load_auto_continue_config() -> AutoContinueConfig: + """Load the policy once for one public conversation invocation.""" + from hermes_cli.config import load_config + + return resolve_auto_continue_config(load_config() or {}) + + +def build_auto_continue_user_message(config: AutoContinueConfig) -> str: + return f"{AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER}\n{config.prompt}" + + +def is_auto_continue_on_max_iterations_prompt(content: Any) -> bool: + """Return whether content is the persisted synthetic continuation marker.""" + if not isinstance(content, str): + return False + return content.startswith(AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER) + + +def is_real_user_message(message: Any) -> bool: + return ( + isinstance(message, dict) + and message.get("role") == "user" + and not is_auto_continue_on_max_iterations_prompt(message.get("content")) + ) + + +def find_last_real_user_message_index(messages: list[dict[str, Any]]) -> int | None: + """Find the last user-authored turn, excluding synthetic continuations.""" + for index in range(len(messages) - 1, -1, -1): + if is_real_user_message(messages[index]): + return index + return None + + +def _cycle_api_calls(result: dict[str, Any]) -> int: + raw = result.get("cycle_api_calls", result.get("api_calls", 0)) + try: + return max(0, int(raw or 0)) + except (TypeError, ValueError, OverflowError): + return 0 + + +def run_with_auto_continue( + agent: Any, + run_turn: Callable[..., dict[str, Any]], + *, + user_message: Any, + system_message: str | None = None, + conversation_history: list[dict[str, Any]] | None = None, + task_id: str | None = None, + stream_callback: Callable[..., Any] | None = None, + persist_user_message: Any | None = None, + persist_user_timestamp: float | None = None, + moa_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Run one public request as one or more independently bounded turns.""" + config = load_auto_continue_config() + if not config.can_continue(used=0): + return run_turn( + agent, + user_message, + system_message, + conversation_history, + task_id, + stream_callback, + persist_user_message, + persist_user_timestamp=persist_user_timestamp, + moa_config=moa_config, + ) + + current_user_message = user_message + current_history = conversation_history + current_persist_message = persist_user_message + current_persist_timestamp = persist_user_timestamp + total_api_calls = 0 + calls_by_cycle: list[int] = [] + auto_continues_used = 0 + cleanup_errors: list[str] = [] + completion_hook_errors: list[str] = [] + + while True: + continuation_available = config.can_continue(used=auto_continues_used) + result = run_turn( + agent, + current_user_message, + system_message, + current_history, + task_id, + stream_callback, + current_persist_message, + persist_user_timestamp=current_persist_timestamp, + moa_config=moa_config, + _defer_iteration_limit_fallback=continuation_available, + _total_api_call_offset=total_api_calls, + ) + + cycle_calls = _cycle_api_calls(result) + calls_by_cycle.append(cycle_calls) + total_api_calls += cycle_calls + cleanup_errors.extend(result.get("cleanup_errors") or []) + completion_hook_errors.extend(result.get("completion_hook_errors") or []) + + continuation_ready = bool( + result.get("iteration_limit_continuation_ready", False) + ) + if not continuation_ready or not continuation_available: + if auto_continues_used: + result["api_calls"] = total_api_calls + result["cycle_api_calls"] = cycle_calls + result["api_calls_by_cycle"] = calls_by_cycle + result["auto_continues_used"] = auto_continues_used + if cleanup_errors: + result["cleanup_errors"] = cleanup_errors + if completion_hook_errors: + result["completion_hook_errors"] = completion_hook_errors + result.pop("iteration_limit_continuation_ready", None) + return result + + auto_continues_used += 1 + current_history = result.get("messages") or current_history + current_user_message = build_auto_continue_user_message(config) + # Synthetic continuations are deliberate durable user-role boundaries; + # never rewrite them with the original turn's API-only persistence value. + current_persist_message = None + current_persist_timestamp = None + + try: + agent._touch_activity( + "starting fresh max-iteration continuation turn " + f"({auto_continues_used}/{config.max_auto_continues})" + ) + agent._emit_status( + "šŸ” Auto-continuing in a fresh turn after iteration exhaustion " + f"({auto_continues_used}/{config.max_auto_continues})" + ) + except Exception: + pass diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 1f72873edc106..c6d601aa2742a 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -976,6 +976,8 @@ def run_conversation( persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, moa_config: Optional[dict[str, Any]] = None, + _defer_iteration_limit_fallback: bool = False, + _total_api_call_offset: int = 0, ) -> Dict[str, Any]: """ Run a complete conversation with tool calling until completion. @@ -6726,6 +6728,8 @@ def _perform_api_call(next_api_kwargs): _should_review_memory=_should_review_memory, _turn_exit_reason=_turn_exit_reason, _pending_verification_response=_pending_verification_response, + _defer_iteration_limit_fallback=_defer_iteration_limit_fallback, + total_api_call_count=_total_api_call_offset + api_call_count, _pending_verification_response_previewed=_pending_verification_response_previewed, ) diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index 29a4b8691ae83..7b671c8e363b2 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -21,6 +21,10 @@ logger = logging.getLogger(__name__) +MAX_ITERATION_CONTINUATION_BOUNDARY = ( + "The iteration budget was exhausted; starting a fresh continuation turn." +) + # Lone surrogate code points are invalid in UTF-8 and crash json.dumps # inside the OpenAI SDK. Used by every surrogate-sanitization helper # below as well as by run_agent and the CLI for paste-from-clipboard @@ -311,6 +315,30 @@ def close_interrupted_tool_sequence(messages: list, final_response: Any = None) return True +def close_max_iteration_continuation_sequence(messages: list) -> bool: + """Close an exhausted turn before a synthetic continuation user is added. + + A fresh turn is allowed to append its synthetic ``user`` only after the + exhausted turn has a provider-valid assistant boundary. This mirrors the + interrupted-tool repair above, but is intentionally idempotent because the + finalizer may be retried after a persistence/cleanup failure. + + Mutates ``messages`` in place. Returns True when a boundary was appended. + """ + if not messages: + return False + last = messages[-1] + if isinstance(last, dict) and last.get("role") == "assistant": + return False + messages.append( + { + "role": "assistant", + "content": MAX_ITERATION_CONTINUATION_BOUNDARY, + } + ) + return True + + def _strip_non_ascii(text: str) -> str: """Remove non-ASCII characters, replacing with closest ASCII equivalent or removing. diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 4e2d318b2e2c2..d1ea92aa83005 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -82,6 +82,8 @@ def finalize_turn( _should_review_memory, _turn_exit_reason, _pending_verification_response=None, + _defer_iteration_limit_fallback=False, + total_api_call_count=None, _pending_verification_response_previewed=False, ): """Run the post-loop finalization and return the turn ``result`` dict. @@ -91,6 +93,9 @@ def finalize_turn( """ from agent.conversation_loop import logger + if total_api_call_count is None: + total_api_call_count = api_call_count + budget_exhausted = ( api_call_count >= agent.max_iterations or agent.iteration_budget.remaining <= 0 @@ -109,6 +114,7 @@ def finalize_turn( iteration_limit_fallback = False preserved_verification_fallback = False + iteration_limit_continuation_ready = False if continuation_budget_exhausted: # A verification/continuation gate deliberately withheld a composed # answer, then consumed the remaining budget before producing a newer @@ -125,21 +131,27 @@ def finalize_turn( 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..." + if _defer_iteration_limit_fallback: + # The outer coordinator will start a new, independently bounded + # turn after this turn has completed normal cleanup/persistence. + # Do not reset this turn's IterationBudget or inject its next user + # message here: either would bypass the turn-context lifecycle. + iteration_limit_continuation_ready = True + else: + # Final continuation budget exhausted (or feature disabled) — keep + # the established one-call, toolless summary fallback. + agent._emit_status( + f"āš ļø Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) " + "— asking model to summarise" ) - final_response = agent._handle_max_iterations(messages, api_call_count) - iteration_limit_fallback = True + 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 @@ -263,9 +275,28 @@ def finalize_turn( # 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. + # Establish the provider-valid boundary before entering the fallible + # persistence/cleanup block. If scaffolding cleanup or session persistence + # raises, the outer coordinator must never receive a ``tool`` tail together + # with continuation readiness. The in-block call below is retained as an + # idempotent re-check after trailing-scaffolding cleanup. + if iteration_limit_continuation_ready: + from agent.message_sanitization import ( + close_max_iteration_continuation_sequence, + ) + + close_max_iteration_continuation_sequence(messages) + try: agent._drop_trailing_empty_response_scaffolding(messages) + if iteration_limit_continuation_ready: + from agent.message_sanitization import ( + close_max_iteration_continuation_sequence, + ) + + close_max_iteration_continuation_sequence(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 @@ -378,11 +409,13 @@ def finalize_turn( _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 " + "Turn ended: reason=%s model=%s cycle_api_calls=%d/%d " + "total_api_calls=%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, + total_api_call_count, _budget_used, _budget_max, _turn_tool_count, _last_msg_role, _resp_len, agent.session_id or "none", @@ -439,7 +472,7 @@ def finalize_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: + if not interrupted and not iteration_limit_continuation_ready: try: if agent._turn_completion_explainer_enabled(): _stripped = (final_response or "").strip() @@ -575,7 +608,7 @@ def finalize_turn( "final_response": final_response, "last_reasoning": last_reasoning, "messages": messages, - "api_calls": api_call_count, + "api_calls": total_api_call_count, "completed": completed, "turn_exit_reason": _turn_exit_reason, "failed": failed, @@ -605,6 +638,10 @@ def finalize_turn( ).get("service_tier"), "session_id": agent.session_id, } + if _defer_iteration_limit_fallback or total_api_call_count != api_call_count: + result["cycle_api_calls"] = api_call_count + if iteration_limit_continuation_ready: + result["iteration_limit_continuation_ready"] = True 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 diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 6b0a6248eb828..5986abf300967 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -8,6 +8,7 @@ import { UserMessageText } from '@/components/assistant-ui/thread/user-message-t import { Codicon } from '@/components/ui/codicon' import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' +import { realUserMessageOrdinal } from '@/lib/auto-continue' import { triggerHaptic } from '@/lib/haptics' import { StopFilled } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -120,23 +121,9 @@ export const UserMessage: FC<{ return null }) - const runtimeUserOrdinal = useAuiState(s => { - let ordinal = 0 - - for (const message of s.thread.messages) { - if (message.role !== 'user') { - continue - } - - if (message.id === s.message.id) { - return ordinal - } - - ordinal += 1 - } - - return null - }) + const runtimeUserOrdinal = useAuiState(s => + realUserMessageOrdinal(s.thread.messages, s.message.id) + ) const attachmentRefs = useAuiState(s => { const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } @@ -212,10 +199,16 @@ export const UserMessage: FC<{ const hasBody = messageText.trim().length > 0 const isLatestUser = messageId === latestUserId const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel) + // Restore (re-run this exact prompt) is available everywhere the Stop button // isn't — including mid-stream on older prompts, since the action interrupts // the live turn before rewinding. - const showRestore = !readOnly && !showStop && Boolean(onRequestRestoreConfirm) && hasBody + const showRestore = + !readOnly && + !showStop && + runtimeUserOrdinal !== null && + Boolean(onRequestRestoreConfirm) && + hasBody const bubbleClassName = cn( USER_BUBBLE_BASE_CLASS, diff --git a/apps/desktop/src/lib/auto-continue.test.ts b/apps/desktop/src/lib/auto-continue.test.ts new file mode 100644 index 0000000000000..679088e2bfb24 --- /dev/null +++ b/apps/desktop/src/lib/auto-continue.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { + AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER, + isAutoContinueUserContent, + realUserMessageOrdinal +} from './auto-continue' + +const messages = [ + { id: 'u1', role: 'user', content: [{ type: 'text', text: 'first' }] }, + { id: 'a1', role: 'assistant', content: [{ type: 'text', text: 'first work' }] }, + { + id: 'auto', + role: 'user', + content: [ + { + type: 'text', + text: `${AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER}\nContinue safely.` + } + ] + }, + { id: 'boundary', role: 'assistant', content: [{ type: 'text', text: 'continued' }] }, + { id: 'u2', role: 'user', content: [{ type: 'text', text: 'second' }] } +] + +describe('auto-continuation user targeting', () => { + it('does not count the synthetic marker as a user-authored restore target', () => { + expect(realUserMessageOrdinal(messages, 'u1')).toBe(0) + expect(realUserMessageOrdinal(messages, 'auto')).toBeNull() + expect(realUserMessageOrdinal(messages, 'u2')).toBe(1) + }) + + it('recognises only the exact persisted marker prefix', () => { + expect( + isAutoContinueUserContent( + `${AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER}\nContinue safely.` + ) + ).toBe(true) + expect(isAutoContinueUserContent('please continue after max iterations')).toBe(false) + expect(isAutoContinueUserContent(undefined)).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/auto-continue.ts b/apps/desktop/src/lib/auto-continue.ts new file mode 100644 index 0000000000000..ad0f4486209c0 --- /dev/null +++ b/apps/desktop/src/lib/auto-continue.ts @@ -0,0 +1,55 @@ +export const AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER = + '[Continuing after max-iteration exhaustion]' + +type MessageLike = { + id?: string + role?: string + content?: unknown +} + +function contentText(content: unknown): string { + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return '' + } + + return content + .map(part => { + if (!part || typeof part !== 'object') { + return '' + } + + const text = (part as { text?: unknown }).text + + return typeof text === 'string' ? text : '' + }) + .join('') +} + +export function isAutoContinueUserContent(content: unknown): boolean { + return contentText(content).startsWith(AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER) +} + +export function realUserMessageOrdinal( + messages: readonly MessageLike[], + currentMessageId: string +): number | null { + let ordinal = 0 + + for (const message of messages) { + if (message.role !== 'user' || isAutoContinueUserContent(message.content)) { + continue + } + + if (message.id === currentMessageId) { + return ordinal + } + + ordinal += 1 + } + + return null +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 8857bca43277c..cc0fb4a65c3b2 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -305,6 +305,11 @@ export interface HermesConfig { reasoning_effort?: string personalities?: Record service_tier?: string + auto_continue_on_max_iterations?: { + enabled?: boolean + max_auto_continues?: number + prompt?: string + } } display?: { personality?: string diff --git a/cli-config.yaml.example b/cli-config.yaml.example index d1ce3b08e2b4c..329ad7fdf16da 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -814,6 +814,14 @@ agent: # underneath this wrapper — this is the Hermes-level loop. # api_max_retries: 3 + # Opt in to a fresh, independently bounded turn when one turn exhausts + # max_turns before finishing. The separate max_auto_continues cap prevents + # unbounded continuation; 0 disables it even when enabled is true. + # auto_continue_on_max_iterations: + # enabled: false + # max_auto_continues: 0 + # prompt: "" + # After the agent edits code without fresh passing verification, nudge it to # verify before finishing. The default "auto" enables it on interactive # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables diff --git a/cli.py b/cli.py index 34bb7bb6b35bf..eafaaa9888c95 100644 --- a/cli.py +++ b/cli.py @@ -8070,12 +8070,11 @@ def retry_last(self): print("(._.) No messages to retry.") return None - # Walk backwards to find the last user message - last_user_idx = None - for i in range(len(self.conversation_history) - 1, -1, -1): - if self.conversation_history[i].get("role") == "user": - last_user_idx = i - break + # Synthetic max-iteration continuation users belong to the preceding + # real user turn and must never become /retry targets. + from agent.auto_continue import find_last_real_user_message_index + + last_user_idx = find_last_real_user_message_index(self.conversation_history) if last_user_idx is None: print("(._.) No user message found to retry.") @@ -8118,10 +8117,13 @@ def undo_last(self, n: int = 1, prefill: bool = True): if n < 1: n = 1 - # Walk backwards collecting the indices of the last N user messages. + # Walk backwards collecting real user turns. Synthetic max-iteration + # continuations are part of the preceding turn, not separate undo steps. + from agent.auto_continue import is_real_user_message + user_indices = [] for i in range(len(self.conversation_history) - 1, -1, -1): - if self.conversation_history[i].get("role") == "user": + if is_real_user_message(self.conversation_history[i]): user_indices.append(i) if len(user_indices) >= n: break diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 780026bfa2fa6..90624595c64f6 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2603,14 +2603,14 @@ async def _handle_retry_command(self, event: MessageEvent) -> str: session_entry = await self.async_session_store.get_or_create_session(source) history = await self.async_session_store.load_transcript(session_entry.session_id) - # Find the last user message + # Find the last user-authored turn. A persisted max-iteration marker is + # an internal continuation boundary, not a retry target. + from agent.auto_continue import find_last_real_user_message_index + last_user_msg = None - last_user_idx = None - for i in range(len(history) - 1, -1, -1): - if history[i].get("role") == "user": - last_user_msg = history[i].get("content", "") - last_user_idx = i - break + last_user_idx = find_last_real_user_message_index(history) + if last_user_idx is not None: + last_user_msg = history[last_user_idx].get("content", "") if not last_user_msg: return t("gateway.retry.no_previous") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6a5f39028ff9c..bfb0a7c4c360f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -983,6 +983,15 @@ def _ensure_hermes_home_managed(home: Path): # on flaky primaries; raise it if you prefer to tolerate longer # provider hiccups on a single provider. "api_max_retries": 3, + # Opt-in continuation when one turn consumes max_turns before it can + # finish. Each continuation is a fresh turn with its own max_turns + # budget and normal finalization; max_auto_continues is a separate hard + # cap. A value <= 0 disables continuation even when enabled is true. + "auto_continue_on_max_iterations": { + "enabled": False, + "max_auto_continues": 0, + "prompt": "", + }, "service_tier": "", # Tool-use enforcement: injects system prompt guidance that tells the # model to actually call tools instead of describing intended actions. diff --git a/hermes_state.py b/hermes_state.py index 9ad8761670dc0..7b40c905d912b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -7638,13 +7638,26 @@ def list_recent_user_messages( By default only active messages are returned. """ active_clause = "" if include_inactive else " AND active = 1" + # Synthetic continuation markers remain durable user-role rows so the + # provider transcript preserves assistant/user alternation, but they do + # not represent user-authored turns for /undo, /rewind, or retry target + # selection. Filter before LIMIT so repeated continuations cannot hide + # the requested number of real user messages. + from agent.auto_continue import AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER + with self._lock: cursor = self._conn.execute( "SELECT id, timestamp, content FROM messages " - "WHERE session_id = ? AND role = 'user'" + "WHERE session_id = ? AND role = 'user' " + "AND (content IS NULL OR substr(content, 1, ?) != ?)" f"{active_clause} " "ORDER BY id DESC LIMIT ?", - (session_id, int(limit)), + ( + session_id, + len(AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER), + AUTO_CONTINUE_ON_MAX_ITERATIONS_MARKER, + int(limit), + ), ) rows = cursor.fetchall() diff --git a/run_agent.py b/run_agent.py index 166ce7dfb97e0..3d8ed2b5527b6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6734,6 +6734,7 @@ def run_conversation( set_accounting_context, ) from agent.conversation_loop import run_conversation + from agent.auto_continue import run_with_auto_continue from agent.portal_tags import ( reset_conversation_context, set_conversation_context, @@ -6759,14 +6760,15 @@ def run_conversation( # which may be observed from another thread. with scoped_runtime_main({}): try: - return run_conversation( + return run_with_auto_continue( self, - user_message, - system_message, - conversation_history, - task_id, - stream_callback, - persist_user_message, + run_conversation, + user_message=user_message, + system_message=system_message, + conversation_history=conversation_history, + task_id=task_id, + stream_callback=stream_callback, + persist_user_message=persist_user_message, persist_user_timestamp=persist_user_timestamp, moa_config=moa_config, ) diff --git a/tests/agent/test_auto_continue_config.py b/tests/agent/test_auto_continue_config.py new file mode 100644 index 0000000000000..c73817306f729 --- /dev/null +++ b/tests/agent/test_auto_continue_config.py @@ -0,0 +1,123 @@ +"""Configuration contract for max-iteration auto-continuation.""" + +import os +from unittest.mock import patch + +import pytest + + +@pytest.mark.parametrize( + ("raw", "enabled", "maximum"), + [ + ({}, False, 0), + ({"agent": {}}, False, 0), + ( + { + "agent": { + "auto_continue_on_max_iterations": { + "enabled": False, + "max_auto_continues": 3, + } + } + }, + False, + 3, + ), + ( + { + "agent": { + "auto_continue_on_max_iterations": { + "enabled": True, + "max_auto_continues": "2", + } + } + }, + True, + 2, + ), + ( + { + "agent": { + "auto_continue_on_max_iterations": { + "enabled": True, + "max_auto_continues": 0, + } + } + }, + True, + 0, + ), + ( + { + "agent": { + "auto_continue_on_max_iterations": { + "enabled": True, + "max_auto_continues": -4, + } + } + }, + True, + 0, + ), + ( + {"agent": {"auto_continue_on_max_iterations": True}}, + True, + 0, + ), + ], +) +def test_config_normalization_is_fail_closed_and_bounded(raw, enabled, maximum): + from agent.auto_continue import resolve_auto_continue_config + + config = resolve_auto_continue_config(raw) + + assert config.enabled is enabled + assert config.max_auto_continues == maximum + assert config.can_continue(used=maximum - 1) is (enabled and maximum > 0) + assert config.can_continue(used=maximum) is False + + +def test_default_config_is_disabled_and_dashboard_schema_exposes_nested_fields(): + from hermes_cli.config import DEFAULT_CONFIG + from hermes_cli.web_server import CONFIG_SCHEMA + + config = DEFAULT_CONFIG["agent"]["auto_continue_on_max_iterations"] + assert config["enabled"] is False + assert config["max_auto_continues"] == 0 + assert config["prompt"] == "" + + assert CONFIG_SCHEMA["agent.auto_continue_on_max_iterations.enabled"]["type"] == "boolean" + assert CONFIG_SCHEMA["agent.auto_continue_on_max_iterations.max_auto_continues"]["type"] == "number" + assert CONFIG_SCHEMA["agent.auto_continue_on_max_iterations.prompt"]["type"] == "string" + + +def test_policy_loads_from_real_profile_config(tmp_path): + from agent.auto_continue import load_auto_continue_config + + (tmp_path / "config.yaml").write_text( + """ +agent: + auto_continue_on_max_iterations: + enabled: true + max_auto_continues: 2 + prompt: Continue from persisted state. +""".lstrip(), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + policy = load_auto_continue_config() + + assert policy.enabled is True + assert policy.max_auto_continues == 2 + assert policy.prompt == "Continue from persisted state." + + +def test_policy_does_not_hide_live_config_parse_errors(): + from agent.auto_continue import load_auto_continue_config + + with ( + patch("hermes_cli.config.load_config", side_effect=RuntimeError("invalid YAML")), + pytest.raises(RuntimeError, match="invalid YAML"), + ): + load_auto_continue_config() diff --git a/tests/agent/test_turn_finalizer_cleanup_guard.py b/tests/agent/test_turn_finalizer_cleanup_guard.py index f4c992fd26e85..456fde8b06624 100644 --- a/tests/agent/test_turn_finalizer_cleanup_guard.py +++ b/tests/agent/test_turn_finalizer_cleanup_guard.py @@ -68,7 +68,8 @@ def _cleanup_task_resources(self, *a, **k): raise RuntimeError("docker teardown EOF") def _drop_trailing_empty_response_scaffolding(self, *a, **k): - pass + if "drop_scaffolding" in self._raise_in: + raise RuntimeError("scaffolding cleanup failed") def _persist_session(self, *a, **k): if "persist_session" in self._raise_in: @@ -106,6 +107,7 @@ def _run( final_response=None, api_call_count=3, turn_exit_reason="unknown", + defer_iteration_limit_fallback=False, ): messages = [ {"role": "user", "content": "do a thing"}, @@ -132,6 +134,7 @@ def _run( original_user_message="do a thing", _should_review_memory=False, _turn_exit_reason=turn_exit_reason, + _defer_iteration_limit_fallback=defer_iteration_limit_fallback, ) @@ -182,3 +185,16 @@ def test_text_response_on_last_allowed_call_is_completed(): ) assert result["final_response"] == "final report" assert result["completed"] is True + + +def test_scaffolding_cleanup_failure_cannot_reintroduce_tool_to_user_continuation(): + agent = _StubAgent(raise_in=("drop_scaffolding",)) + + result = _run(agent, defer_iteration_limit_fallback=True) + + assert result["iteration_limit_continuation_ready"] is True + assert result["messages"][-2]["role"] == "tool" + assert result["messages"][-1]["role"] == "assistant" + assert result["cleanup_errors"] == [ + "persist_session: scaffolding cleanup failed" + ] diff --git a/tests/agent/test_turn_finalizer_iteration_limit_exit.py b/tests/agent/test_turn_finalizer_iteration_limit_exit.py index f1920634b779b..f65a3664abce1 100644 --- a/tests/agent/test_turn_finalizer_iteration_limit_exit.py +++ b/tests/agent/test_turn_finalizer_iteration_limit_exit.py @@ -95,14 +95,22 @@ def _finalize( exit_reason, api_call_count=60, pending_verification_response=None, + messages=None, + defer_iteration_limit_fallback=None, + total_api_call_count=None, ): + kwargs = {} + if defer_iteration_limit_fallback is not None: + kwargs["_defer_iteration_limit_fallback"] = defer_iteration_limit_fallback + if total_api_call_count is not None: + kwargs["total_api_call_count"] = total_api_call_count return finalize_turn( agent, final_response=final_response, api_call_count=api_call_count, interrupted=False, failed=False, - messages=[{"role": "user", "content": "task"}], + messages=messages or [{"role": "user", "content": "task"}], conversation_history=[], effective_task_id="task", turn_id="turn", @@ -111,8 +119,86 @@ def _finalize( _should_review_memory=False, _turn_exit_reason=exit_reason, _pending_verification_response=pending_verification_response, + **kwargs, + ) + + +def test_deferred_exhaustion_finalizes_tool_tail_for_a_fresh_continuation_turn( + monkeypatch, +): + """Intermediate exhaustion is finalized without summary or tool -> user.""" + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: []) + agent = _LimitAgent(max_iterations=1) + messages = [ + {"role": "user", "content": "task"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "test", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "result"}, + ] + + result = _finalize( + agent, + final_response=None, + exit_reason="budget_exhausted", + api_call_count=1, + total_api_call_count=5, + messages=messages, + defer_iteration_limit_fallback=True, ) + assert agent._handle_max_iterations_called is False + assert result["iteration_limit_continuation_ready"] is True + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + assert result["api_calls"] == 5 + assert result["cycle_api_calls"] == 1 + assert agent.persisted_messages[-2]["role"] == "tool" + assert agent.persisted_messages[-1]["role"] == "assistant" + assert agent.persisted_messages[-1].get("tool_calls") is None + + +def test_continuation_boundary_finalization_is_idempotent(monkeypatch): + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: []) + agent = _LimitAgent(max_iterations=1) + messages = [ + {"role": "user", "content": "task"}, + {"role": "tool", "tool_call_id": "call-1", "content": "result"}, + ] + + first = _finalize( + agent, + final_response=None, + exit_reason="budget_exhausted", + api_call_count=1, + messages=messages, + defer_iteration_limit_fallback=True, + ) + finalized_messages = first["messages"] + second = _finalize( + agent, + final_response=None, + exit_reason="budget_exhausted", + api_call_count=1, + messages=finalized_messages, + defer_iteration_limit_fallback=True, + ) + + boundaries = [ + message + for message in second["messages"] + if message.get("role") == "assistant" + and "fresh continuation turn" in str(message.get("content", "")) + ] + assert len(boundaries) == 1 + def test_pending_verify_response_is_preserved_for_cron_delivery(monkeypatch): """A held-back verification response survives last-turn exhaustion.""" @@ -125,11 +211,13 @@ def test_pending_verify_response_is_preserved_for_cron_delivery(monkeypatch): final_response=None, exit_reason="unknown", pending_verification_response=report, + defer_iteration_limit_fallback=True, ) assert result["final_response"] == report assert result["turn_exit_reason"] == "max_iterations_reached(60/60)" assert agent._handle_max_iterations_called is False + assert "iteration_limit_continuation_ready" not in result def test_pending_pre_verify_response_is_preserved_on_budget_exhaustion(monkeypatch): @@ -191,18 +279,20 @@ def test_short_preserved_verification_response_is_not_rewritten(monkeypatch): def test_text_response_exit_not_rewritten_at_iteration_limit(monkeypatch): monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: []) - agent = _LimitAgent(budget_remaining=5) + agent = _LimitAgent(budget_remaining=0) exit_reason = "text_response(finish_reason=stop)" result = _finalize( agent, final_response="normal answer", exit_reason=exit_reason, - api_call_count=59, + api_call_count=60, + defer_iteration_limit_fallback=True, ) assert result["turn_exit_reason"] == exit_reason assert agent._handle_max_iterations_called is False + assert "iteration_limit_continuation_ready" not in result @pytest.mark.parametrize( diff --git a/tests/cli/test_cli_retry.py b/tests/cli/test_cli_retry.py index b287b45754f61..752049aeb549c 100644 --- a/tests/cli/test_cli_retry.py +++ b/tests/cli/test_cli_retry.py @@ -3,6 +3,9 @@ from tests.cli.test_cli_init import _make_cli +AUTO_CONTINUE_MARKER = "[Continuing after max-iteration exhaustion]" + + def test_retry_last_truncates_history_before_requeueing_message(): cli = _make_cli() cli.conversation_history = [ @@ -47,3 +50,45 @@ def put(self, value): assert queued == ["retry me"] assert cli.conversation_history == [] + + +def test_retry_ignores_synthetic_continuation_user_marker(): + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "original task"}, + {"role": "assistant", "content": "calling a tool"}, + {"role": "tool", "tool_call_id": "call-1", "content": "result"}, + {"role": "assistant", "content": "Starting a fresh continuation turn."}, + {"role": "user", "content": f"{AUTO_CONTINUE_MARKER}\nkeep going"}, + {"role": "assistant", "content": "finished"}, + ] + + retry_msg = cli.retry_last() + + assert retry_msg == "original task" + assert cli.conversation_history == [] + + +def test_undo_counts_a_synthetic_continuation_as_part_of_the_original_turn(): + cli = _make_cli() + cli._session_db = None + cli.session_id = None + cli.agent = None + prefills = [] + cli._prefill_input_buffer = prefills.append + cli.conversation_history = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "original task"}, + {"role": "assistant", "content": "Starting a fresh continuation turn."}, + {"role": "user", "content": f"{AUTO_CONTINUE_MARKER}\nkeep going"}, + {"role": "assistant", "content": "finished"}, + ] + + cli.undo_last() + + assert cli.conversation_history == [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "first answer"}, + ] + assert prefills == ["original task"] diff --git a/tests/gateway/test_retry_replacement.py b/tests/gateway/test_retry_replacement.py index 3a6d0665875b1..86fcfc95f773a 100644 --- a/tests/gateway/test_retry_replacement.py +++ b/tests/gateway/test_retry_replacement.py @@ -10,6 +10,9 @@ from gateway.session import SessionStore +AUTO_CONTINUE_MARKER = "[Continuing after max-iteration exhaustion]" + + @pytest.mark.asyncio async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path, monkeypatch): # Pin DEFAULT_DB_PATH so SessionDB() doesn't write to the real ~/.hermes/state.db. @@ -98,3 +101,39 @@ async def fake_handle_message(event): ) assert captured["text"] == "real message" + + +@pytest.mark.asyncio +async def test_gateway_retry_ignores_synthetic_continuation_user_marker(tmp_path): + config = MagicMock() + config.sessions_dir = tmp_path + config.max_context_messages = 20 + gw = GatewayRunner.__new__(GatewayRunner) + gw.config = config + gw.session_store = MagicMock() + + session_entry = MagicMock(session_id="test-session") + session_entry.last_prompt_tokens = 55 + gw.session_store.get_or_create_session.return_value = session_entry + gw.session_store.load_transcript.return_value = [ + {"role": "user", "content": "original task"}, + {"role": "assistant", "content": "Starting a fresh continuation turn."}, + {"role": "user", "content": f"{AUTO_CONTINUE_MARKER}\nkeep going"}, + {"role": "assistant", "content": "finished"}, + ] + gw.session_store.rewrite_transcript = MagicMock() + + captured = {} + + async def fake_handle_message(event): + captured["text"] = event.text + return "ok" + + gw._handle_message = AsyncMock(side_effect=fake_handle_message) + + await gw._handle_retry_command( + MessageEvent(text="/retry", message_type=MessageType.TEXT, source=MagicMock()) + ) + + assert captured["text"] == "original task" + gw.session_store.rewrite_transcript.assert_called_once_with("test-session", []) diff --git a/tests/hermes_state/test_auto_continue_user_targeting.py b/tests/hermes_state/test_auto_continue_user_targeting.py new file mode 100644 index 0000000000000..13e987070deb6 --- /dev/null +++ b/tests/hermes_state/test_auto_continue_user_targeting.py @@ -0,0 +1,38 @@ +"""Synthetic auto-continuation users persist but are not user-turn targets.""" + +import pytest + +from hermes_state import SessionDB + + +MARKER = "[Continuing after max-iteration exhaustion]" + + +@pytest.fixture() +def db(tmp_path): + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.create_session("session", source="test") + yield session_db + session_db.close() + + +def test_recent_user_messages_ignore_persisted_auto_continuation_markers(db): + original_id = db.append_message("session", "user", "original task") + lowercase_literal_id = db.append_message( + "session", + "user", + "[continuing after max-iteration exhaustion] is text I authored", + ) + db.append_message( + "session", + "assistant", + "The iteration budget was exhausted; starting a fresh continuation turn.", + ) + marker_id = db.append_message("session", "user", f"{MARKER}\nkeep going") + db.append_message("session", "assistant", "finished") + + rows = db.list_recent_user_messages("session", limit=2) + persisted = db.get_messages("session") + + assert [row["id"] for row in rows] == [lowercase_literal_id, original_id] + assert any(row["id"] == marker_id and row["role"] == "user" for row in persisted) diff --git a/tests/run_agent/test_auto_continue_max_iterations.py b/tests/run_agent/test_auto_continue_max_iterations.py new file mode 100644 index 0000000000000..2d347319c2b28 --- /dev/null +++ b/tests/run_agent/test_auto_continue_max_iterations.py @@ -0,0 +1,319 @@ +"""Current-turn lifecycle regressions for max-iteration auto-continuation. + +These tests intentionally exercise the public ``AIAgent.run_conversation`` +entrypoint. A helper that merely resets ``IterationBudget`` in the inner loop +is not sufficient: every continuation must pass through turn_context and +turn_finalizer as a fresh, independently bounded turn. +""" + +from __future__ import annotations + +import copy +import uuid +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +MARKER = "[Continuing after max-iteration exhaustion]" + + +def _tool_definitions() -> list[dict]: + return [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "test tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + + +def _tool_response(label: str): + call = SimpleNamespace( + id=f"call_{label}_{uuid.uuid4().hex[:8]}", + type="function", + function=SimpleNamespace(name="web_search", arguments="{}"), + ) + message = SimpleNamespace(content="", tool_calls=[call]) + return SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="tool_calls")], + model="test/model", + usage=None, + ) + + +def _text_response(content: str): + message = SimpleNamespace(content=content, tool_calls=None) + return SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + model="test/model", + usage=None, + ) + + +def _config(*, enabled: bool, maximum: int) -> dict: + return { + "agent": { + "verify_on_stop": False, + "api_max_retries": 1, + "auto_continue_on_max_iterations": { + "enabled": enabled, + "max_auto_continues": maximum, + "prompt": "Continue from the current state without repeating work.", + }, + } + } + + +def _make_agent(*, platform: str = "cli") -> AIAgent: + with ( + patch("run_agent.get_tool_definitions", return_value=_tool_definitions()), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + max_iterations=1, + platform=platform, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.client = MagicMock() + agent._api_max_retries = 1 + agent._handle_max_iterations = MagicMock(return_value="normal exhaustion summary") + return agent + + +def _run_with_responses(agent: AIAgent, config: dict, responses: list): + requests: list[list[dict]] = [] + pending = list(responses) + + def create(**kwargs): + requests.append(copy.deepcopy(kwargs["messages"])) + response = pending.pop(0) + if isinstance(response, BaseException): + raise response + return response + + agent.client.chat.completions.create.side_effect = create + with ( + patch("hermes_cli.config.load_config", return_value=config), + patch("run_agent.handle_function_call", return_value="tool output"), + ): + result = agent.run_conversation("finish the task") + return result, requests + + +def _roles_without_system(messages: list[dict]) -> list[str]: + return [m.get("role") for m in messages if m.get("role") != "system"] + + +def _assert_provider_valid_roles(messages: list[dict]) -> None: + roles = _roles_without_system(messages) + for previous, current in zip(roles, roles[1:]): + assert (previous, current) != ("tool", "user") + assert not (previous == current and current in {"user", "assistant"}) + + +@pytest.mark.parametrize("platform", ["cli", "telegram", "tui"]) +def test_tool_tail_continues_as_fresh_provider_valid_turn_with_explicit_accounting( + platform, +): + agent = _make_agent(platform=platform) + + from agent import conversation_loop, turn_finalizer + + real_build_turn_context = conversation_loop.build_turn_context + real_finalize_turn = turn_finalizer.finalize_turn + with ( + patch.object( + conversation_loop, + "build_turn_context", + wraps=real_build_turn_context, + ) as build_turn_context, + patch.object( + turn_finalizer, + "finalize_turn", + wraps=real_finalize_turn, + ) as finalize_turn, + ): + result, requests = _run_with_responses( + agent, + _config(enabled=True, maximum=1), + [_tool_response("first"), _text_response("finished")], + ) + + assert result["final_response"] == "finished" + assert len(requests) == 2 + assert build_turn_context.call_count == 2 + assert finalize_turn.call_count == 2 + + second_request = requests[1] + _assert_provider_valid_roles(second_request) + assert _roles_without_system(second_request) == [ + "user", + "assistant", + "tool", + "assistant", + "user", + ] + synthetic_users = [ + m for m in second_request + if m.get("role") == "user" and str(m.get("content", "")).startswith(MARKER) + ] + assert len(synthetic_users) == 1 + assert sum(m.get("role") == "system" for m in requests[0]) == 1 + assert sum(m.get("role") == "system" for m in second_request) == 1 + assert requests[0][0]["content"] == second_request[0]["content"] + + assert result["api_calls"] == 2 + assert result["cycle_api_calls"] == 1 + assert result["api_calls_by_cycle"] == [1, 1] + assert result["auto_continues_used"] == 1 + agent._handle_max_iterations.assert_not_called() + + +def test_repeated_exhaustion_uses_independently_bounded_turns_without_state_leakage(): + agent = _make_agent() + config = _config(enabled=True, maximum=2) + + first_result, first_requests = _run_with_responses( + agent, + config, + [ + _tool_response("first-a"), + _tool_response("first-b"), + _text_response("first finished"), + ], + ) + second_result, second_requests = _run_with_responses( + agent, + config, + [ + _tool_response("second-a"), + _tool_response("second-b"), + _text_response("second finished"), + ], + ) + + assert first_result["api_calls_by_cycle"] == [1, 1, 1] + assert second_result["api_calls_by_cycle"] == [1, 1, 1] + assert first_result["auto_continues_used"] == 2 + assert second_result["auto_continues_used"] == 2 + assert len(first_requests) == len(second_requests) == 3 + for request in [*first_requests, *second_requests]: + _assert_provider_valid_roles(request) + + +def test_continuation_limit_falls_back_once_after_last_fresh_turn(): + agent = _make_agent() + + result, requests = _run_with_responses( + agent, + _config(enabled=True, maximum=1), + [_tool_response("first"), _tool_response("second")], + ) + + assert result["final_response"] == "normal exhaustion summary" + assert len(requests) == 2 + assert result["api_calls"] == 2 + assert result["cycle_api_calls"] == 1 + assert result["api_calls_by_cycle"] == [1, 1] + assert result["auto_continues_used"] == 1 + agent._handle_max_iterations.assert_called_once() + + +@pytest.mark.parametrize( + "config", + [ + {}, + {"agent": {}}, + _config(enabled=False, maximum=3), + _config(enabled=True, maximum=0), + _config(enabled=True, maximum=-1), + ], +) +def test_disabled_default_zero_and_negative_config_keep_existing_fallback(config): + agent = _make_agent() + + result, requests = _run_with_responses( + agent, + config, + [_tool_response("only")], + ) + + assert result["final_response"] == "normal exhaustion summary" + assert len(requests) == 1 + assert result["api_calls"] == 1 + assert "cycle_api_calls" not in result + assert "api_calls_by_cycle" not in result + assert "auto_continues_used" not in result + agent._handle_max_iterations.assert_called_once() + + +def test_api_error_during_continuation_does_not_start_another_cycle(): + agent = _make_agent() + + result, requests = _run_with_responses( + agent, + _config(enabled=True, maximum=3), + [_tool_response("first"), RuntimeError("provider failed")], + ) + + assert len(requests) == 2 + assert result["auto_continues_used"] == 1 + assert result["failed"] is True + assert result["api_calls_by_cycle"] == [1, 1] + + +def test_cleanup_errors_from_an_exhausted_cycle_survive_the_final_cycle(): + agent = _make_agent() + original_cleanup = agent._drop_trailing_empty_response_scaffolding + cleanup_calls = 0 + + def fail_first_cleanup(messages): + nonlocal cleanup_calls + cleanup_calls += 1 + # build_turn_context performs one early persistence cleanup; fail the + # next call, which is the exhausted turn's finalizer cleanup. + if cleanup_calls == 2: + raise RuntimeError("first-cycle cleanup failed") + return original_cleanup(messages) + + agent._drop_trailing_empty_response_scaffolding = fail_first_cleanup + + result, requests = _run_with_responses( + agent, + _config(enabled=True, maximum=1), + [_tool_response("first"), _text_response("finished")], + ) + + assert len(requests) == 2 + assert result["final_response"] == "finished" + assert result["cleanup_errors"] == [ + "persist_session: first-cycle cleanup failed" + ] + + +def test_preexisting_cancellation_never_auto_continues(): + agent = _make_agent() + with patch("run_agent._set_interrupt"): + agent.interrupt() + + result, requests = _run_with_responses( + agent, + _config(enabled=True, maximum=3), + [], + ) + + assert requests == [] + assert result["interrupted"] is True + assert "auto_continues_used" not in result diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6d3bae7b612b4..cacb80edc337d 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7970,6 +7970,76 @@ def replace_messages(self, session_id, messages): server._sessions.pop("sid", None) +def test_prompt_submit_user_ordinal_ignores_auto_continuation_marker(monkeypatch): + """Desktop restore ordinals target user-authored turns, not synthetic users.""" + seen = {} + + class _Agent: + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + seen["history"] = conversation_history + return { + "final_response": "edited reply", + "messages": [ + *(conversation_history or []), + {"role": "user", "content": prompt}, + {"role": "assistant", "content": "edited reply"}, + ], + } + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + marker = "[Continuing after max-iteration exhaustion]\nContinue safely." + original_history = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "first work"}, + {"role": "user", "content": marker}, + {"role": "assistant", "content": "first turn complete"}, + {"role": "user", "content": "second"}, + {"role": "assistant", "content": "second reply"}, + ] + server._sessions["sid"] = _session(agent=_Agent(), history=original_history) + + class _StubDb: + def __init__(self): + self.replaced = [] + + def replace_messages(self, session_id, messages): + self.replaced.append((session_id, list(messages))) + + stub_db = _StubDb() + try: + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_get_usage", lambda _a: {}) + monkeypatch.setattr(server, "render_message", lambda _t, _c: "") + monkeypatch.setattr(server, "_emit", lambda *a: None) + monkeypatch.setattr(server, "_get_db", lambda: stub_db) + + response = server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": { + "session_id": "sid", + "text": "edited second", + "truncate_before_user_ordinal": 1, + }, + } + ) + + assert response.get("result"), f"got error: {response.get('error')}" + assert seen["history"] == original_history[:4] + assert stub_db.replaced == [("session-key", original_history[:4])] + finally: + server._sessions.pop("sid", None) + + # --------------------------------------------------------------------------- # session.interrupt must only cancel pending prompts owned by the calling # session — it must not blast-resolve clarify/sudo/secret prompts on diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index de2e6c39b0142..20d515f089b44 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1837,6 +1837,36 @@ def test_command_dispatch_retry_finds_last_user_message(server): assert server._sessions[sid]["history_version"] == 1 +def test_command_dispatch_retry_ignores_synthetic_continuation_marker(server): + sid = "test-session" + history = [ + {"role": "user", "content": "original task"}, + {"role": "assistant", "content": "Starting a fresh continuation turn."}, + { + "role": "user", + "content": "[Continuing after max-iteration exhaustion]\nkeep going", + }, + {"role": "assistant", "content": "finished"}, + ] + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "retry-auto-continue", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"]["message"] == "original task" + assert server._sessions[sid]["history"] == [] + + def test_command_dispatch_retry_empty_history(server): """command.dispatch /retry with empty history returns error.""" sid = "test-session" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 95461910856a7..0fad2be6963f0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10843,7 +10843,11 @@ def _(rid, params: dict) -> dict: except (TypeError, ValueError): return _err(rid, 4004, "truncate_before_user_ordinal must be an integer") history = session.get("history", []) - user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"] + from agent.auto_continue import is_real_user_message + + user_indices = [ + i for i, message in enumerate(history) if is_real_user_message(message) + ] # Reject out-of-range ordinals on BOTH ends. A negative value would # otherwise sail past the upper-bound check and hit Python's negative # indexing below (user_indices[-1] -> the LAST user turn), silently @@ -15557,12 +15561,11 @@ def _(rid, params: dict) -> dict: history = session.get("history", []) if not history: return _err(rid, 4018, "no previous user message to retry") - # Walk backwards to find the last user message - last_user_idx = None - for i in range(len(history) - 1, -1, -1): - if history[i].get("role") == "user": - last_user_idx = i - break + # Synthetic max-iteration continuation markers are internal parts of + # the preceding real user turn and must not become retry targets. + from agent.auto_continue import find_last_real_user_message_index + + last_user_idx = find_last_real_user_message_index(history) if last_user_idx is None: return _err(rid, 4018, "no previous user message to retry") content = history[last_user_idx].get("content", "") From d2f43863731793d27620470198af0058a8adf5c9 Mon Sep 17 00:00:00 2001 From: Stefan van Biljon Date: Sun, 26 Jul 2026 19:23:51 +0100 Subject: [PATCH 2/2] test(tui): accept persisted user message in continuation fake --- tests/test_tui_gateway_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index cacb80edc337d..38742a34572a1 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7976,7 +7976,11 @@ def test_prompt_submit_user_ordinal_ignores_auto_continuation_marker(monkeypatch class _Agent: def run_conversation( - self, prompt, conversation_history=None, stream_callback=None + self, + prompt, + conversation_history=None, + stream_callback=None, + persist_user_message=None, ): seen["history"] = conversation_history return {