refactor(agent): flatten interrupt() claim closures, group lazy-origin imports, reflow literals/log calls byte-identically

This commit is contained in:
Teknium
2026-09-02 19:05:00 -07:00
parent b3d75f78f3
commit 83f8de7eab
3 changed files with 166 additions and 293 deletions
+115 -200
View File
@@ -16,31 +16,10 @@ from dataclasses import dataclass, field, fields
from typing import Any, Dict, List, Optional
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.conversation_compression import (
conversation_history_after_compression, # noqa: F401 — resolved lazily by turn_overflow/turn_preflight/turn_recovery (tests patch it here)
)
from agent.fast_mode import begin_turn as begin_fast_mode_turn
from agent.message_metadata import append_message
from agent.turn_context import (
PreflightCompressionTimedOut,
build_turn_context,
)
from agent.turn_retry_state import TurnRetryState
from agent.runtime_cwd import resolve_agent_cwd
from agent.message_sanitization import (
_repair_tool_call_arguments,
_sanitize_surrogates,
)
# Must mirror _STALE_TOOL_CALL_MARKER_RE in hermes_state.py; kept local so importing
# hermes_state (module-level DEFAULT_DB_PATH) is not forced at load time.
_STALE_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$")
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH,
_estimate_tools_tokens_rough,
estimate_messages_tokens_rough, # noqa: F401 — resolved lazily by agent.turn_request_assembly (tests patch it here)
estimate_request_tokens_rough, # noqa: F401 — resolved lazily by turn_overflow/turn_preflight/turn_recovery (tests patch it here)
save_context_length, # noqa: F401 — resolved lazily by agent.turn_overflow (tests patch it here)
)
from agent.message_sanitization import _repair_tool_call_arguments, _sanitize_surrogates
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, _estimate_tools_tokens_rough
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import (
build_prompt_cache_plan,
@@ -48,17 +27,15 @@ from agent.prompt_caching import (
strip_anthropic_cache_control,
strip_anthropic_tool_cache_control,
)
from agent.retry_utils import ( # noqa: F401 — resolved lazily by agent.turn_* (tests patch them here)
adaptive_rate_limit_backoff,
jittered_backoff,
)
from agent.turn_recovery import ( # noqa: F401 — resolved lazily by agent.turn_response_check
describe_invalid_response,
interruptible_backoff_sleep,
validate_response_shape,
)
# Bind before the turn starts so a source-tree swap cannot load a skewed
# finalizer at turn end.
from agent.runtime_cwd import resolve_agent_cwd
from agent.turn_context import PreflightCompressionTimedOut, build_turn_context
from agent.turn_retry_state import TurnRetryState
# Phase helpers of the turn loop, bound at import so a source-tree swap cannot load a
# skewed phase mid-turn.
from agent.turn_api_call import handle_api_interrupt, nous_rate_limit_guard, perform_api_call
from agent.turn_api_error import handle_api_error
from agent.turn_api_request import build_api_request
from agent.turn_final_response import finish_text_response
from agent.turn_finalizer import finalize_turn
from agent.turn_iteration_prep import (
announce_api_call,
@@ -66,22 +43,35 @@ from agent.turn_iteration_prep import (
begin_iteration,
prepare_iteration,
)
from agent.turn_loop_errors import handle_outer_loop_error
from agent.turn_preflight_gate import run_preflight_gate
from agent.turn_request_assembly import assemble_api_request
from agent.turn_api_request import build_api_request
from agent.turn_api_call import handle_api_interrupt, nous_rate_limit_guard, perform_api_call
from agent.turn_response_check import check_api_response
from agent.turn_api_error import handle_api_error
from agent.turn_final_response import finish_text_response
from agent.turn_tool_round import run_tool_round
from agent.turn_response_intake import normalize_model_response
from agent.turn_loop_errors import handle_outer_loop_error
from agent.turn_tool_round import run_tool_round
from hermes_logging import set_session_context
# Resolved lazily by agent.turn_* via ``from agent.conversation_loop import X`` — tests
# patch them here, so they must stay bound in this namespace.
from agent.conversation_compression import conversation_history_after_compression # noqa: F401
from agent.model_metadata import ( # noqa: F401
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
save_context_length,
)
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff # noqa: F401
from agent.turn_recovery import ( # noqa: F401
describe_invalid_response,
interruptible_backoff_sleep,
validate_response_shape,
)
from tools.skill_provenance import set_current_write_origin
from utils import base_url_host_matches
logger = logging.getLogger(__name__)
# Must mirror _STALE_TOOL_CALL_MARKER_RE in hermes_state.py; kept local so importing
# hermes_state (module-level DEFAULT_DB_PATH) is not forced at load time.
_STALE_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$")
# Shared by _apply_active_turn_redirect and the api_messages ghost-row filter so both sites cannot drift.
_INTERRUPT_SCAFFOLD_MARKER = "[This response was interrupted by a user correction.]"
@@ -89,10 +79,9 @@ _INTERRUPT_SCAFFOLD_MARKER = "[This response was interrupted by a user correctio
# One-time wrap-up notice appended when a wall-clock run budget (--run-budget) crosses 80%.
RUN_BUDGET_WRAPUP_NOTICE = (
"[SYSTEM NOTICE — run time budget nearly exhausted] "
"Run time budget nearly exhausted. Stop new discovery/verification work "
"now. Produce the required final deliverable (answer/JSON/summary) from "
"the state you already have, completing only mandatory writes."
"[SYSTEM NOTICE — run time budget nearly exhausted] Run time budget nearly exhausted. "
"Stop new discovery/verification work now. Produce the required final deliverable "
"(answer/JSON/summary) from the state you already have, completing only mandatory writes."
)
@@ -112,22 +101,17 @@ def _midturn_request_pressure_tokens(
)
native = estimate_native_responses_preflight_tokens(
agent,
api_messages,
system_prompt=effective_system or "",
agent, api_messages, system_prompt=effective_system or "",
tools=getattr(agent, "tools", None) or None,
)
if isinstance(native, int) and not isinstance(native, bool) and native >= 0:
return native
except Exception:
logger.debug(
"native Responses mid-turn estimate unavailable; "
"using generic transcript estimate",
"native Responses mid-turn estimate unavailable; using generic transcript estimate",
exc_info=True,
)
return approx_tokens + (
_estimate_tools_tokens_rough(agent.tools) if agent.tools else 0
)
return approx_tokens + (_estimate_tools_tokens_rough(agent.tools) if agent.tools else 0)
def _review_input_budget_exhausted(agent: Any) -> bool:
@@ -210,15 +194,13 @@ def _should_skip_model_call_for_reference_handoff(
# Fallback final_response for the sole-handoff skip (#80622); finalize_turn appends it as a
# fresh assistant row, so it must not replay the last assistant text.
_HANDOFF_SKIP_FINAL_RESPONSE = (
"Context was compacted. The previous response is complete — "
"awaiting your next message."
"Context was compacted. The previous response is complete — awaiting your next message."
)
# Terminal final_response when compression timed out while the request was still oversized (#98722).
_COMPRESSION_TIMEOUT_FINAL_RESPONSE = (
"Context compression timed out without reducing this conversation. "
"No messages were dropped. Start a fresh session with /new, or check "
"auxiliary.compression before retrying /compress."
"Context compression timed out without reducing this conversation. No messages were "
"dropped. Start a fresh session with /new, or check auxiliary.compression before retrying /compress."
)
@@ -385,30 +367,22 @@ def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str
model = getattr(agent, "model", "") or "the selected model"
logger.warning(
"Ollama runtime context too small for Hermes tool use: "
"model=%s provider=%s base_url=%s runtime_context=%d "
"minimum_context=%d estimated_request_tokens=%d tool_count=%d "
"session=%s",
model,
getattr(agent, "provider", "") or "unknown",
getattr(agent, "base_url", "") or "unknown base URL",
runtime_ctx,
MINIMUM_CONTEXT_LENGTH,
request_tokens,
len(getattr(agent, "tools", None) or []),
"Ollama runtime context too small for Hermes tool use: model=%s provider=%s base_url=%s "
"runtime_context=%d minimum_context=%d estimated_request_tokens=%d tool_count=%d session=%s",
model, getattr(agent, "provider", "") or "unknown",
getattr(agent, "base_url", "") or "unknown base URL", runtime_ctx, MINIMUM_CONTEXT_LENGTH,
request_tokens, len(getattr(agent, "tools", None) or []),
getattr(agent, "session_id", None) or "none",
)
return (
f"Ollama loaded `{model}` with only {runtime_ctx:,} tokens of runtime "
f"context, but Hermes needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens "
"for reliable tool use.\n\n"
"Increase the Ollama context for this model and restart/reload the "
"model before trying again. A known-good starting point is 65,536 "
"tokens. In Hermes config, set `model.ollama_num_ctx: 65536` "
"(and `model.context_length: 65536` if you also override the displayed "
"model context). If you manage the model through an Ollama Modelfile, "
"set `PARAMETER num_ctx 65536` there instead."
f"Ollama loaded `{model}` with only {runtime_ctx:,} tokens of runtime context, but Hermes "
f"needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens for reliable tool use.\n\n"
"Increase the Ollama context for this model and restart/reload the model before trying "
"again. A known-good starting point is 65,536 tokens. In Hermes config, set "
"`model.ollama_num_ctx: 65536` (and `model.context_length: 65536` if you also override the "
"displayed model context). If you manage the model through an Ollama Modelfile, set "
"`PARAMETER num_ctx 65536` there instead."
)
@@ -429,10 +403,8 @@ def _maybe_grow_local_window(agent: Any, compressor: Any,
if current_window <= 0:
return None
return maybe_grow_window(
getattr(agent, "model", "") or "",
base_url=base_url,
session_tokens=int(request_tokens),
current_window=current_window,
getattr(agent, "model", "") or "", base_url=base_url,
session_tokens=int(request_tokens), current_window=current_window,
)
except Exception as exc: # noqa: BLE001 — growth must never break a turn
logger.debug("local window growth check failed: %s", exc)
@@ -453,11 +425,7 @@ def _nous_entitlement_message(capability: str) -> str:
)
account_info = get_nous_portal_account_info(force_fresh=True)
message = format_nous_portal_entitlement_message(
account_info,
capability=capability,
)
return message or ""
return format_nous_portal_entitlement_message(account_info, capability=capability) or ""
except Exception:
return ""
@@ -609,10 +577,7 @@ def _billing_failure_result(
unverified = bool(getattr(classified, "billing_unverified", False))
if guidance is None:
guidance = _billing_or_entitlement_message(
capability="model access",
provider=provider,
base_url=str(base_url),
model=model,
capability="model access", provider=provider, base_url=str(base_url), model=model,
unverified=unverified,
)
final = _billing_terminal_label(summary, unverified)
@@ -629,9 +594,7 @@ def _billing_failure_result(
# Classifier's own retry verdict so the UI shows Retry only when a re-run can differ.
"failure_retryable": bool(classified.retryable),
"billing_unverified": unverified,
"billing_block": _billing_block_dict(
provider, base_url, model, guidance, unverified=unverified
),
"billing_block": _billing_block_dict(provider, base_url, model, guidance, unverified=unverified),
}
@@ -723,18 +686,16 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
stored_prompt = raw_prompt or None
except Exception as exc:
logger.warning(
"Session DB get_session failed for system-prompt restore "
"(session=%s): %s. Falling back to fresh build — prefix "
"cache will miss for this turn.",
"Session DB get_session failed for system-prompt restore (session=%s): %s. "
"Falling back to fresh build — prefix cache will miss for this turn.",
agent.session_id, exc,
)
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
if _bot_chat_prompt_stale(agent, stored_prompt):
logger.info(
"Bot Chat capability epoch changed for session %s; rebuilding "
"system prompt to adopt the new capability surface (one-time "
"prefix-cache break).",
"Bot Chat capability epoch changed for session %s; rebuilding system prompt to "
"adopt the new capability surface (one-time prefix-cache break).",
agent.session_id,
)
agent._session_title_hint = "Bot Chat"
@@ -751,9 +712,8 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# once per capability change). on_session_start not re-fired: continuation.
_persist_system_prompt(
agent,
"Session DB update_system_prompt failed after Bot Chat "
"capability refresh (session=%s): %s. The refresh will "
"re-fire next turn.",
"Session DB update_system_prompt failed after Bot Chat capability refresh "
"(session=%s): %s. The refresh will re-fire next turn.",
)
return
# Continuing session — reuse the exact system prompt from the
@@ -784,18 +744,15 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
agent.session_id, getattr(agent, "model", "") or "", getattr(agent, "provider", "") or "",
)
if conversation_history and stored_state in ("null", "empty"):
# Continuing session with an unusable stored prompt: every turn now rebuilds
# and the prefix cache misses every time.
logger.warning(
"Stored system prompt for session %s is %s; rebuilding "
"from scratch this turn. Prefix cache will miss until "
"the rebuild persists. Investigate the previous turn's "
"Stored system prompt for session %s is %s; rebuilding from scratch this turn. Prefix "
"cache will miss until the rebuild persists. Investigate the previous turn's "
"update_system_prompt write path.",
agent.session_id, stored_state,
)
@@ -807,9 +764,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_start",
session_id=agent.session_id,
model=agent.model,
"on_session_start", session_id=agent.session_id, model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
@@ -826,9 +781,8 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
_persist_system_prompt(
agent,
"Session DB update_system_prompt failed for session %s: "
"%s. Subsequent turns will rebuild the system prompt and "
"miss the prefix cache.",
"Session DB update_system_prompt failed for session %s: %s. Subsequent turns will "
"rebuild the system prompt and miss the prefix cache.",
persist_tools=True,
)
@@ -879,15 +833,12 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
# Named so _is_synthetic_compression_user_turn can recognize a crash-persisted nudge by
# content (SessionDB projection strips the _length_continuation_nudge tag).
_LENGTH_CONTINUATION_NETWORK_STUB = (
"[System: The previous response was cut off by a "
"network error mid-stream. Continue exactly where "
"you left off. Do not restart or repeat prior text. "
"Finish the answer directly.]"
"[System: The previous response was cut off by a network error mid-stream. Continue exactly "
"where you left off. Do not restart or repeat prior text. Finish the answer directly.]"
)
_LENGTH_CONTINUATION_OUTPUT_LIMIT = (
"[System: Your previous response was truncated by the output "
"length limit. Continue exactly where you left off. Do not "
"restart or repeat prior text. Finish the answer directly.]"
"[System: Your previous response was truncated by the output length limit. Continue exactly "
"where you left off. Do not restart or repeat prior text. Finish the answer directly.]"
)
# The dropped-tools variant interpolates tool names; matched by prefix.
_LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX = "[System: Your previous tool call "
@@ -897,60 +848,48 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3])
return (
f"{_LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX}"
f"({tool_list}) was too large and "
"the stream timed out before it "
"could be delivered. Do NOT retry "
"the same tool call with the same "
"large content. Instead, break the "
"content into multiple smaller tool "
"calls (e.g. use multiple patch calls "
"or write smaller files). Each tool "
"call's arguments must be under ~8K "
"tokens to avoid stream timeouts.]"
f"{_LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX}({tool_list}) was too large and "
"the stream timed out before it could be delivered. Do NOT retry the same tool call "
"with the same large content. Instead, break the content into multiple smaller tool "
"calls (e.g. use multiple patch calls or write smaller files). Each tool call's "
"arguments must be under ~8K tokens to avoid stream timeouts.]"
)
elif is_partial_stub:
return _LENGTH_CONTINUATION_NETWORK_STUB
else:
return _LENGTH_CONTINUATION_OUTPUT_LIMIT
return _LENGTH_CONTINUATION_NETWORK_STUB if is_partial_stub else _LENGTH_CONTINUATION_OUTPUT_LIMIT
# Codex/Responses turns that returned only internal reasoning: a bare retry would be
# byte-identical, so the model repeats it.
_CODEX_INCOMPLETE_NUDGE = (
"[System: Your previous response contained only internal reasoning and "
"never produced a visible answer or tool call. Do not keep thinking. "
"Produce your final answer as plain text now (or make the tool call "
"you were planning).]"
"[System: Your previous response contained only internal reasoning and never produced a "
"visible answer or tool call. Do not keep thinking. Produce your final answer as plain text "
"now (or make the tool call you were planning).]"
)
# Re-prompt after an acknowledgment-only Codex/Responses reply.
_CODEX_ACK_CONTINUATION_NUDGE = (
"[System: Continue now. Execute the required tool calls and only "
"send your final answer after completing the task.]"
"[System: Continue now. Execute the required tool calls and only send your final answer "
"after completing the task.]"
)
# Re-prompt for finish_reason="tool_calls" with empty tool_calls (an interrupt mid-retry can persist it).
_DROPPED_TOOLCALL_NUDGE_CONTENT = (
"Your previous turn indicated a tool call but none was "
"included. Do not narrate a plan or restate intent — issue "
"the actual tool call now to continue the task."
"Your previous turn indicated a tool call but none was included. Do not narrate a plan or "
"restate intent — issue the actual tool call now to continue the task."
)
# Re-prompt for an empty response after tool calls (#9400); the metadata flag does not
# survive SessionDB projection, so it is matched by content.
_EMPTY_TOOL_RESPONSE_NUDGE = (
"You just executed tool calls but returned an "
"empty response. Please process the tool "
"You just executed tool calls but returned an empty response. Please process the tool "
"results above and continue with the task."
)
# Shared trailer for both content-policy refusal paths so guidance cannot drift.
_CONTENT_POLICY_RECOVERY_HINT = (
"Try rephrasing the request, narrowing the context, or "
"adding a fallback provider with `hermes fallback add`."
"Try rephrasing the request, narrowing the context, or adding a fallback provider with "
"`hermes fallback add`."
)
@@ -971,9 +910,7 @@ def _canonicalize_tool_call_arguments(arg_str: str) -> str:
cached = _CANON_ARGS_CACHE.get(arg_str)
if cached is not None:
return cached
canonical = json.dumps(
json.loads(arg_str), separators=(",", ":"), sort_keys=True,
)
canonical = json.dumps(json.loads(arg_str), separators=(",", ":"), sort_keys=True)
_CANON_ARGS_CACHE[arg_str] = canonical
_canon_args_cache_bytes += len(arg_str) + len(canonical)
while len(_CANON_ARGS_CACHE) > _CANON_ARGS_CACHE_MAX or (
@@ -1035,12 +972,9 @@ def _invalid_tool_name_error_content(name: str, valid_tool_names) -> str:
error; a nonempty wrong name still gets the catalog to self-correct."""
if not (name or "").strip():
return (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
"Tool call rejected: the tool name was empty. If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; otherwise reply in plain text."
)
available = ", ".join(sorted(valid_tool_names))
return f"Tool '{name}' does not exist. Available tools: {available}"
@@ -1138,9 +1072,7 @@ def _provider_overflow_exhausted_result(
logger.error(
"%sContext compression failed after %d attempts; rebuilt request "
"remains over threshold at ~%s tokens.",
agent.log_prefix,
max_compression_attempts,
f"{request_pressure_tokens:,}",
agent.log_prefix, max_compression_attempts, f"{request_pressure_tokens:,}",
)
agent._persist_session(messages, conversation_history)
return _partial_turn_result(
@@ -1208,9 +1140,7 @@ def _ensure_cached_system_prompt_static(agent, system_message=None) -> None:
failover to a cache-on provider."""
from agent.system_prompt import reconstruct_static_prefix
reconstruct_static_prefix(
agent, system_message=system_message, log_label="failover redecoration"
)
reconstruct_static_prefix(agent, system_message=system_message, log_label="failover redecoration")
def _peel_moa_guidance(
@@ -1233,9 +1163,7 @@ def _redecorate_prompt_cache_for_provider(
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]] | tuple[List[Dict[str, Any]], Optional[Dict[str, Any]], List[Dict[str, Any]]]:
"""Strip and re-apply cache_control for the *current* provider policy — failover
``continue`` paths reuse ``api_messages`` (#72626). MoA guidance is peeled and rebased."""
messages: List[Dict[str, Any]] = [
dict(m) if isinstance(m, dict) else m for m in (api_messages or [])
]
messages: List[Dict[str, Any]] = [dict(m) if isinstance(m, dict) else m for m in (api_messages or [])]
prepared = moa_prepared
guidance = prepared.get("guidance") if isinstance(prepared, dict) else None
if guidance:
@@ -1339,10 +1267,8 @@ def _apply_context_engine_selection(
)
except Exception:
logger.warning(
"Context engine select_context hook failed; using unmodified "
"request messages (session=%s)",
session_label,
exc_info=True,
"Context engine select_context hook failed; using unmodified request messages (session=%s)",
session_label, exc_info=True,
)
return api_messages
@@ -1354,8 +1280,7 @@ def _apply_context_engine_selection(
return selected
logger.warning(
"Context engine select_context returned an invalid value "
"(not a non-empty list of dicts); ignoring (session=%s)",
session_label,
"(not a non-empty list of dicts); ignoring (session=%s)", session_label,
)
return api_messages
@@ -1384,8 +1309,7 @@ def _notify_context_engine_turn_complete(
except Exception:
logger.warning(
"Context engine on_turn_complete hook failed (session=%s)",
getattr(agent, "session_id", None) or "-",
exc_info=True,
getattr(agent, "session_id", None) or "-", exc_info=True,
)
@@ -1409,9 +1333,7 @@ def _preflight_timeout_result(agent, exc, conversation_history) -> Dict[str, Any
"""Typed recovery result when turn-start preflight compression timed out (#98424): no
provider call was sent, and surfaces would otherwise hide the actionable guidance."""
logger.warning(
"Turn-start preflight compression timed out — ending turn with "
"typed recovery result: %s",
exc,
"Turn-start preflight compression timed out — ending turn with typed recovery result: %s", exc,
)
# Clear the tripwire slot note_turn_start registered (the early return skips the persist
# funnel). The user row is deliberately NOT persisted (#7100).
@@ -1430,11 +1352,10 @@ def _preflight_timeout_result(agent, exc, conversation_history) -> Dict[str, Any
class _LoopState:
"""Every local the turn loop threads through the phase helpers in ``agent/turn_*.py``.
Each helper takes the loop locals it needs as keyword arguments named exactly like
these fields and returns a verdict dataclass whose non-``action``/``result`` fields
carry the same names; :func:`_run_phase` passes and copies them back by name, so a
field added to a helper's signature or verdict needs a field here and nothing else.
Per-iteration slots (``response`` … ``assistant_message``) are rebound by the phases
Helpers take the loop locals they need as keyword arguments named like these fields and
return a verdict whose non-``action``/``result`` fields carry the same names;
:func:`_run_phase` passes and copies them back by name, so a new helper input/output
needs a field here and nothing else. Per-iteration slots are rebound by the phases
before any later phase reads them, exactly as the former inline locals were."""
# Fixed for the turn.
@@ -1453,9 +1374,9 @@ class _LoopState:
active_system_prompt: Any
current_turn_user_idx: Any
_preflight_compression_blocked: Any
# Per-turn compression attempt cap shared by the pre-API gate, 413 handlers and
# post-tool compaction; a consecutive-ineffective-attempt backstop, rearmed only
# after a provider response reports a prompt below threshold. Default 3 if unset.
# Per-turn compression attempt cap shared by the pre-API gate, 413 handlers and post-tool
# compaction: a consecutive-ineffective-attempt backstop, rearmed only after a provider
# response reports a prompt below threshold.
max_compression_attempts: Any
api_call_count: int = 0
final_response: Any = None
@@ -1476,11 +1397,10 @@ class _LoopState:
# context-recovery contract (error/partial/compression_exhausted) (#98722).
_compression_timeout_exhausted: bool = False
_turn_exit_reason: str = "unknown" # Diagnostic: why the loop ended
# Last answer held back by a verification gate: if the continuation exhausts the
# budget this is the best user-facing result, distinct from error/recovery text.
_pending_verification_response: Any = None
# Whether that candidate was already streamed as interim; ``_response_was_previewed``
# Last answer held back by a verification gate (best user-facing result if the continuation
# exhausts the budget) and whether it was already streamed as interim; ``_response_was_previewed``
# is set ONLY if it becomes the final response (#65919).
_pending_verification_response: Any = None
_pending_verification_response_previewed: bool = False
# If pre-API compression fires after MoA advisors ran, retain their guidance and
# rebase it onto the compacted transcript next iteration — no second fan-out.
@@ -1554,17 +1474,12 @@ def run_conversation(
persist_user_platform_id: Optional[str] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Run a complete conversation with tool calling until completion.
"""Run a complete conversation with tool calling until completion; returns the result dict.
Args:
stream_callback: per-text-delta callback (TTS); None uses the non-streaming path.
persist_user_message: clean text to store when ``user_message`` carries API-only
synthetic prefixes; ``persist_user_timestamp`` / ``persist_user_platform_id``
are stored as metadata (platform id lets restart drain recovery dedup).
persist_user_display_kind/metadata: display-only event rendering (``auto_continue``,
``model_switch``); the model still receives the message unchanged.
Returns: dict with the final response and message history."""
``stream_callback``: per-text-delta callback (TTS). ``persist_user_message``: clean text to
store when ``user_message`` carries API-only synthetic prefixes; timestamp / platform id are
stored as metadata (platform id lets restart drain recovery dedup). ``persist_user_display_*``:
display-only event rendering; the model still receives the message unchanged."""
if moa_config is None:
user_message, moa_config, persist_user_message = _decode_inline_moa_turn(
user_message, persist_user_message
+19 -37
View File
@@ -60,13 +60,10 @@ class DeadlineExpired(TimeoutError):
class SuspectableBackend(Protocol):
"""A stateful backend (MCP connection, browser session, LSP client) the deadline layer can flag.
``run_bounded_*`` calls ``mark_suspect`` on timeout so the owner can health-check or
recycle the backend (``ensure_healthy``) before reuse. ``mark_suspect`` MUST be cheap,
non-blocking, and must not acquire locks the guarded operation may hold — it runs inline
on the event loop / caller's thread while the wedged worker is still alive.
"""
"""A stateful backend (MCP connection, browser session, LSP client) ``run_bounded_*`` flags via
``mark_suspect`` on timeout so the owner can health-check/recycle it before reuse. ``mark_suspect``
MUST be cheap, non-blocking, and must not acquire locks the guarded operation may hold — it runs
inline on the event loop / caller's thread while the wedged worker is still alive."""
def mark_suspect(self, reason: str) -> None: ...
@@ -148,11 +145,8 @@ def resolve_timeout(
default: Optional[float],
env_var: Optional[str] = None,
) -> Optional[float]:
"""Resolve a timeout (seconds) for dotted ``timeouts.<key>`` > ``env_var`` > ``default``.
The winner goes through :func:`clamp_timeout`; invalid config/env values fall
through to the next source with a warning.
"""
"""Resolve a timeout (seconds): dotted ``timeouts.<key>`` > ``env_var`` > ``default``; the winner
goes through :func:`clamp_timeout`, invalid config/env values fall through with a warning."""
raw = _lookup_dotted(_timeouts_section(), key)
if raw is not None:
# Explicit float() so invalid config values FALL THROUGH to env/default instead of
@@ -234,11 +228,9 @@ async def run_bounded_async(
) -> BoundedResult:
"""Await ``awaitable`` under a wall-clock deadline independent of loop timers.
Operation exceptions (incl. ``CancelledError`` from a caller cancelling *us*)
propagate unchanged. On timeout the task is cancelled and **abandoned** (never
awaited — cancellation-shielded scopes are exactly the paths that wedge), and
``on_abandon`` is scheduled as detached best-effort cleanup.
"""
Operation exceptions (incl. ``CancelledError`` from a caller cancelling *us*) propagate
unchanged. On timeout the task is cancelled and **abandoned** (never awaited —
cancellation-shielded scopes are exactly the paths that wedge); ``on_abandon`` runs detached."""
timeout_s = clamp_timeout(timeout)
start = time.monotonic()
if timeout_s is None:
@@ -302,13 +294,10 @@ def run_bounded_sync(
on_timeout: Optional[Callable[[], None]] = None,
backend: object | None = None,
) -> BoundedResult:
"""Run ``fn`` in a daemon worker thread under a wall-clock deadline.
Exceptions re-raise in the caller. On expiry the worker is **abandoned** and
``on_timeout`` runs best-effort in the caller's thread. Every timeout leaks one
daemon thread, so do NOT use per-item in hot loops. The worker runs under
``contextvars.copy_context()`` so secret scope / session id survive the hop.
"""
"""Run ``fn`` in a daemon worker thread under a wall-clock deadline; exceptions re-raise in
the caller. On expiry the worker is **abandoned** (every timeout leaks one daemon thread, so
do NOT use per-item in hot loops) and ``on_timeout`` runs best-effort in the caller's thread.
The worker runs under ``contextvars.copy_context()`` so secret scope / session id survive."""
timeout_s = clamp_timeout(timeout)
start = time.monotonic()
if timeout_s is None:
@@ -355,16 +344,12 @@ def run_bounded_sync(
def kill_process_tree(pid: int, *, sig: Optional[int] = None) -> bool:
"""Terminate ``pid`` and all its descendants, portably.
"""Terminate ``pid`` and all its descendants, portably; True when anything was signalled.
Windows: ``taskkill /F /T`` (``sig`` ignored). POSIX: descendants are snapshotted
via psutil BEFORE signalling (once the parent dies they reparent and a parent walk
finds nothing), then the process group is signalled when ``pid`` leads one, and
every snapshotted descendant individually — which also reaches children that
``setsid`` into their own session. ``sig`` defaults to ``SIGKILL``.
Returns True when the target (or any of its tree) was signalled.
"""
Windows: ``taskkill /F /T`` (``sig`` ignored). POSIX: descendants are snapshotted via psutil
BEFORE signalling (once the parent dies they reparent and a parent walk finds nothing), then
the process group is signalled when ``pid`` leads one, and every snapshotted descendant
individually — which also reaches ``setsid`` children. ``sig`` defaults to ``SIGKILL``."""
if sys.platform == "win32":
try:
from hermes_cli._subprocess_compat import windows_hide_flags
@@ -375,10 +360,7 @@ def kill_process_tree(pid: int, *, sig: Optional[int] = None) -> bool:
try:
proc = subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True,
timeout=15,
check=False,
creationflags=creationflags,
capture_output=True, timeout=15, check=False, creationflags=creationflags,
)
# taskkill exits non-zero for not-found / access-denied (False = nothing terminated).
return proc.returncode == 0
+32 -56
View File
@@ -90,17 +90,14 @@ class InterruptControlMixin:
) -> bool:
"""Request the agent to interrupt its current tool-calling loop (call from another thread).
``message``: new message to include in the response context. ``hard_cancel``: explicit stop;
compression may honor it even while ordinary interrupts are masked. ``tool_reason``: trusted fixed
category safe for tool output. ``require_generation``: activity-generation claim — the interrupt is
published only if the turn's generation still matches at the final mutation edge (claim reserved under
the activity lock, consumed together with the first observable publication); returns False if the turn
resumed meanwhile.
``hard_cancel``: explicit stop; compression may honor it even while ordinary interrupts are masked.
``tool_reason``: trusted fixed category safe for tool output. ``require_generation``: activity-
generation claim — published only if the turn's generation still matches at the final mutation edge;
returns False if the turn resumed meanwhile.
"""
if require_generation is not None:
# RESERVE the abort's generation claim under the SAME lock `_touch_activity` stamps with. Real
# progress invalidates it; it is CONSUMED at the final mutation edge, so a resumed turn abandons
# the abort.
# RESERVE the claim under the SAME lock `_touch_activity` stamps with; real progress invalidates
# it and it is CONSUMED at the final mutation edge, so a resumed turn abandons the abort.
with self._liveness_activity_lock():
if getattr(self, "_turn_liveness_activity_generation", 0) != require_generation:
return False
@@ -123,22 +120,6 @@ class InterruptControlMixin:
if _hard_event is not None:
_hard_event.set()
def _consume_claim_and_publish_first_state() -> bool:
# Final mutation edge: claim consumption and the FIRST observable interrupt publication are ONE
# activity-lock critical section, so either the claim survives and commits before any later
# activity stamp, or the stamp landed first and the abort declines without publishing.
if require_generation is None:
# No claim to race: publish WITHOUT the liveness lock. Bare AIAgent stand-ins in other suites
# lack the liveness seam and would AttributeError.
_publish_interrupt_state()
return True
with self._liveness_activity_lock():
if getattr(self, "_turn_liveness_abort_claim", None) != require_generation:
return False
self._turn_liveness_abort_claim = None
_publish_interrupt_state()
return True
# A hard stop and redirect share one lock so /stop cannot race with an accepted correction and
# accidentally turn itself into a retry. The blocking in-flight-commit wait runs BEFORE the atomic
# claim edge (redirect lock still held); the destructive pending-commit cancel runs AFTER the claim
@@ -150,8 +131,19 @@ class InterruptControlMixin:
when_in_flight=True,
failure_log="Compression hard-cancel fence wait failed",
)
if not _consume_claim_and_publish_first_state():
return False
if require_generation is None:
# No claim to race: publish WITHOUT the liveness lock (bare AIAgent stand-ins in other
# suites lack the liveness seam and would AttributeError).
_publish_interrupt_state()
else:
# Final mutation edge: claim consumption and the FIRST observable publication are ONE
# activity-lock critical section, so either the claim survives and commits before any later
# activity stamp, or the stamp landed first and the abort declines without publishing.
with self._liveness_activity_lock():
if getattr(self, "_turn_liveness_abort_claim", None) != require_generation:
return False
self._turn_liveness_abort_claim = None
_publish_interrupt_state()
if hard_cancel:
_fence_cancel_before_commit(
vars(self).get("_active_compression_commit_fence"),
@@ -201,19 +193,14 @@ class InterruptControlMixin:
*,
tool_reason: Optional[str] = None,
) -> None:
"""Request an explicit stop while preserving the ``interrupt()`` ABI.
Frontends feature-detect this and fall back to legacy ``interrupt()`` for third-party agents.
"""
# Bypass dynamic dispatch: legacy subclasses may override interrupt(message=None) without hard_cancel.
"""Explicit stop preserving the ``interrupt()`` ABI (frontends feature-detect this and fall back to
legacy ``interrupt()`` for third-party agents). Bypasses dynamic dispatch: legacy subclasses may
override interrupt(message=None) without hard_cancel."""
InterruptControlMixin.interrupt(self, message, hard_cancel=True, tool_reason=tool_reason)
def clear_interrupt(self, *, preserve_redirect: bool = False) -> bool:
"""Clear the interrupt request and per-thread tool signal.
``preserve_redirect`` is only for the conversation loop rebuilding the same logical turn after
cancelling a model request; public hard-stop paths clear everything.
"""
"""Clear the interrupt request and per-thread tool signal. ``preserve_redirect`` is only for the
conversation loop rebuilding the same logical turn after cancelling a model request."""
with _ic_lock(self, "_pending_redirect_lock"):
if preserve_redirect and not getattr(self, "_pending_redirect", None):
return False
@@ -233,11 +220,8 @@ class InterruptControlMixin:
return True
def steer(self, text: str) -> bool:
"""Inject user text into the next tool result without interrupting the current tool.
The text is appended to the LAST tool result once the batch finishes, so the model sees it on its next
iteration. Thread-safe; multiple calls concatenate with newlines. Returns False for empty text.
"""
"""Append user text to the LAST tool result once the batch finishes (no interrupt); multiple calls
concatenate with newlines. Returns False for empty text."""
if not text or not text.strip():
return False
cleaned = text.strip()
@@ -247,19 +231,14 @@ class InterruptControlMixin:
return True
def redirect(self, text: str) -> bool:
"""Redirect the active turn without converting it into a new task.
During a model request this cancels only that request: completed messages/tool results are kept, the
displayed partial reasoning becomes assistant context, the correction is appended as a real user
message, and the loop retries. During tool execution it degrades to ``steer()``; Codex app-server uses
native ``turn/steer``. Returns False when there is no live turn or the text is empty.
"""
"""Redirect the active turn without converting it into a new task: during a model request only that
request is cancelled (completed messages kept, partial reasoning becomes assistant context, the
correction is appended as a real user message, the loop retries); during tool execution it degrades
to ``steer()``; Codex app-server uses native ``turn/steer``. False when no live turn / empty text."""
if not text or not text.strip():
return False
cleaned = text.strip()
# Codex owns its internal reasoning/tool loop, so use its first-class
# active-turn steering protocol rather than interrupting the subprocess.
_native_steer = _ic_codex_method(self, "request_steer")
if _native_steer is not None:
with _ic_lock(self, "_pending_redirect_lock"):
@@ -278,9 +257,7 @@ class InterruptControlMixin:
_model_active = getattr(self, "_model_request_active", None)
with _ic_lock(self, "_pending_redirect_lock"):
if _model_active is None or not _model_active.is_set():
# The response completed before we acquired the state lock.
# Reject so the surface queues a new turn.
return False
return False # response completed before we got the lock: surface queues a new turn
existing = getattr(self, "_pending_redirect", None)
if self._interrupt_requested and not existing:
return False
@@ -290,8 +267,7 @@ class InterruptControlMixin:
self._interrupt_requested = True
self._interrupt_message = None
# Interrupt only the model request. Do not fan out to tool workers or
# child agents as interrupt() does.
# Interrupt only the model request — no fan-out to tool workers / child agents as interrupt() does.
_execution_thread_id = getattr(self, "_execution_thread_id", None)
if _execution_thread_id is not None:
_set_interrupt(True, _execution_thread_id)