1710 lines
80 KiB
Python
1710 lines
80 KiB
Python
"""Per-turn setup for ``run_conversation`` (the turn prologue).
|
||
|
||
``run_conversation`` opened with ~470 lines of straight-line setup before the
|
||
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
|
||
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
|
||
restore-or-build, session-row creation (before compression, whose DB writes
|
||
reference the row), preflight context compression, the ``pre_llm_call`` plugin
|
||
hook, external-memory prefetch, and crash-resilience persistence (last, so the
|
||
user row is written once with its final ``api_content`` sidecar).
|
||
|
||
All of that is *prologue* — it runs once per turn, has no back-references into the
|
||
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
|
||
captures those produced values; ``build_turn_context`` performs the setup work and
|
||
returns one. ``run_conversation`` is left to unpack the context and run the loop,
|
||
shrinking the orchestrator by the full prologue.
|
||
|
||
The builder still mutates ``agent`` heavily (counters, thread id, cached prompt,
|
||
session DB) exactly as the inline code did — those side effects are the point. The
|
||
``TurnContext`` it returns carries only the *locals* the loop reads back.
|
||
|
||
Behavior is identical to the original inline prologue; this is a pure
|
||
move-and-name refactor with no semantic change.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, List, Mapping, Optional
|
||
|
||
from agent.conversation_compression import (
|
||
IDLE_COMPACTION_STATUS_TEMPLATE,
|
||
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
|
||
compression_skipped_due_to_lock,
|
||
conversation_history_after_compression,
|
||
recover_rotated_compression_session,
|
||
)
|
||
from agent.context_engine import automatic_compaction_status_message
|
||
from agent.iteration_budget import IterationBudget
|
||
from agent.memory_manager import build_memory_context_block
|
||
from agent.memory_provider import is_trivial_prompt
|
||
from agent.message_metadata import append_message, stamp_message_timestamp
|
||
from agent.model_metadata import (
|
||
anchored_context_tokens,
|
||
estimate_messages_tokens_rough,
|
||
estimate_request_tokens_rough,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _preflight_request_tokens(
|
||
agent: Any,
|
||
messages: List[Dict[str, Any]],
|
||
system_prompt: str,
|
||
) -> int:
|
||
"""Token estimate for automatic preflight compression.
|
||
|
||
When the upcoming request is eligible for native Responses compaction,
|
||
count the checkpoint-pruned wire payload rather than the full durable
|
||
transcript. Auxiliary compression still uses the generic estimator
|
||
(``native_compaction_eligible=False``).
|
||
|
||
Usage-anchored fast path: when a provider-reported usage anchor is
|
||
valid for ``messages`` (see ``anchored_context_tokens``), it already
|
||
covers system prompt + tool schemas + full history EXACTLY as the
|
||
provider counted them, with estimation confined to the messages
|
||
appended since that response. Prefer it over every heuristic.
|
||
"""
|
||
anchored = anchored_context_tokens(
|
||
messages, getattr(agent, "_usage_anchor", None)
|
||
)
|
||
if anchored is not None:
|
||
return anchored
|
||
tools = getattr(agent, "tools", None) or None
|
||
try:
|
||
from agent.codex_responses_adapter import (
|
||
estimate_native_responses_preflight_tokens,
|
||
)
|
||
|
||
native = estimate_native_responses_preflight_tokens(
|
||
agent,
|
||
messages,
|
||
system_prompt=system_prompt or "",
|
||
tools=tools,
|
||
)
|
||
if isinstance(native, int) and not isinstance(native, bool) and native >= 0:
|
||
return native
|
||
except Exception:
|
||
logger.debug(
|
||
"native Responses preflight estimate unavailable; "
|
||
"using generic transcript estimate",
|
||
exc_info=True,
|
||
)
|
||
if _agent_stale_thinking_on_wire(agent):
|
||
return estimate_request_tokens_rough(
|
||
messages,
|
||
system_prompt=system_prompt or "",
|
||
tools=tools,
|
||
)
|
||
return estimate_request_tokens_rough(
|
||
messages,
|
||
system_prompt=system_prompt or "",
|
||
tools=tools,
|
||
charge_stale_thinking=False,
|
||
)
|
||
|
||
|
||
def _agent_stale_thinking_on_wire(agent: Any) -> bool:
|
||
"""Whether the agent's active route replays stale thinking text (#84371).
|
||
|
||
Route facts unavailable (test doubles, partially-built agents) default to
|
||
``True`` — the conservative full charge.
|
||
"""
|
||
try:
|
||
from agent.message_sanitization import stale_thinking_reaches_wire
|
||
|
||
return stale_thinking_reaches_wire(
|
||
getattr(agent, "api_mode", "") or "",
|
||
getattr(agent, "provider", "") or "",
|
||
getattr(agent, "model", "") or "",
|
||
getattr(agent, "base_url", "") or "",
|
||
)
|
||
except Exception:
|
||
return True
|
||
|
||
|
||
def compose_user_api_content(
|
||
content: Any,
|
||
ext_prefetch_cache: str,
|
||
plugin_user_context: str,
|
||
) -> Optional[str]:
|
||
"""Compose the API-bound content of the current turn's user message.
|
||
|
||
Sources: memory-manager prefetch + ``pre_llm_call`` plugin context with
|
||
target="user_message" (the default). Both are appended to the *API copy*
|
||
of the user message only — the stored content stays clean.
|
||
|
||
This is the single source of that composition. The prologue stamps the
|
||
result onto the live message as ``api_content`` (persisted alongside the
|
||
clean content) and the ``api_messages`` build in ``conversation_loop``
|
||
sends the same helper's output, so the persisted sidecar can never drift
|
||
from the bytes on the wire — which is the whole prompt-cache invariant:
|
||
what turn N sends must be what turn N+1 replays.
|
||
|
||
Returns ``None`` when nothing is injected (multimodal/non-string content,
|
||
or no ephemeral context), meaning the message is sent as-is.
|
||
"""
|
||
if not isinstance(content, str):
|
||
return None
|
||
injections = []
|
||
if ext_prefetch_cache:
|
||
fenced = build_memory_context_block(ext_prefetch_cache)
|
||
if fenced:
|
||
injections.append(fenced)
|
||
if plugin_user_context:
|
||
injections.append(plugin_user_context)
|
||
if not injections:
|
||
return None
|
||
return content + "\n\n" + "\n\n".join(injections)
|
||
|
||
|
||
def substitute_api_content(api_msg: Dict[str, Any]) -> Optional[str]:
|
||
"""Pop the ``api_content`` sidecar and substitute it into ``content``.
|
||
|
||
Used at every API-bound message-build site (the ``api_messages`` build in
|
||
``conversation_loop``, the max-iterations summary in
|
||
``chat_completion_helpers``, the chat-completions transport). The sidecar
|
||
carries the exact bytes previously sent to the API for this message when
|
||
they differ from the clean stored content; substituting it here keeps the
|
||
provider prompt-cache prefix byte-stable across turns.
|
||
|
||
Returns the popped sidecar string (for callers that need the value for
|
||
current-turn composition logic) or ``None`` when absent.
|
||
"""
|
||
sidecar = api_msg.pop("api_content", None)
|
||
if (
|
||
isinstance(sidecar, str)
|
||
and sidecar
|
||
and api_msg.get("role") in ("user", "assistant")
|
||
):
|
||
api_msg["content"] = sidecar
|
||
return sidecar
|
||
|
||
|
||
def drop_stale_api_content(msg: Dict[str, Any]) -> None:
|
||
"""Drop the ``api_content`` sidecar from a message whose content was rewritten.
|
||
|
||
Called from every content-rewrite path (historical image strip,
|
||
merge-summary-into-tail, consecutive-user repair merge, stale-confirmation
|
||
redaction). Replaying the pre-rewrite sidecar would resend exactly what
|
||
the rewrite removed, so it must be dropped — the cost is one cache
|
||
boundary miss, never wrong content.
|
||
"""
|
||
msg.pop("api_content", None)
|
||
|
||
|
||
def extract_api_content_sidecar(msg: Mapping[str, Any]) -> Optional[str]:
|
||
"""Extract the ``api_content`` sidecar from a message dict for persistence.
|
||
|
||
Shared by the gateway/branch forwarding sites that copy the sidecar into a
|
||
new row. Returns the string sidecar or ``None`` when absent/non-string.
|
||
"""
|
||
v = msg.get("api_content")
|
||
return v if isinstance(v, str) else None
|
||
|
||
|
||
def consume_gateway_turn_context_notes(agent: Any) -> str:
|
||
"""Pop the gateway's per-turn must-deliver notes off the agent (one-shot).
|
||
|
||
The gateway relocates volatile per-turn facts OUT of the ephemeral system
|
||
prompt (auto-reset notes, the first-contact intro, voice-channel changes)
|
||
and delivers them on the current user message via the api_content sidecar
|
||
instead, so the composed system prompt stays byte-stable turn-over-turn.
|
||
It stages the rendered notes on ``agent._gateway_turn_context_notes``
|
||
right before ``run_conversation``; this consumes them so a cached agent
|
||
can never replay a stale note on a later turn.
|
||
"""
|
||
notes = getattr(agent, "_gateway_turn_context_notes", "") or ""
|
||
if hasattr(agent, "_gateway_turn_context_notes"):
|
||
try:
|
||
agent._gateway_turn_context_notes = ""
|
||
except Exception:
|
||
pass
|
||
return notes if isinstance(notes, str) else ""
|
||
|
||
|
||
def append_notes_to_multimodal_content(content: Any, notes: str) -> bool:
|
||
"""Deliver must-deliver notes on a multimodal (list) user message.
|
||
|
||
``compose_user_api_content`` returns ``None`` for non-string content, so
|
||
sidecar-borne facts would silently drop on image/attachment turns. For
|
||
gateway must-deliver notes we instead append a text part to the content
|
||
list in place — the part becomes durable message content (persisted and
|
||
replayed as-is), which keeps the wire and the transcript byte-identical.
|
||
|
||
Returns ``True`` when a part was appended.
|
||
"""
|
||
if not notes or not isinstance(content, list):
|
||
return False
|
||
try:
|
||
content.append({"type": "text", "text": notes})
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# Surfaces whose sessions must not be auto-titled. The prologue is shared by
|
||
# EVERY agent, not only the ones a human is watching, so membership here is what
|
||
# keeps the titler off machine-driven runs:
|
||
#
|
||
# - cron — the scheduler names its own session after the job in its `finally`
|
||
# block, and the opener is the cron delivery hint, not a user's request.
|
||
# Titling it writes that scaffolding as the visible name for the whole run and
|
||
# bills a side-LLM call per fire, against the same job that sets
|
||
# `skip_memory` / `skip_background_review` to avoid exactly that.
|
||
# - subagent — a delegated child's session is hidden from every picker, so its
|
||
# title is never read. A batch at `max_concurrent_children` would pay N title
|
||
# calls for N names nobody sees.
|
||
_UNTITLED_PLATFORMS = frozenset({"cron", "subagent"})
|
||
|
||
|
||
def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None:
|
||
"""Kick off auto-titling for this session's first user message.
|
||
|
||
Called from the turn prologue, so every surface a human reads (CLI, gateway,
|
||
TUI/desktop, ACP) gets identical behavior without each one re-implementing
|
||
the call. Fully defensive: titling is cosmetic and must never break a turn.
|
||
"""
|
||
session_db = getattr(agent, "_session_db", None)
|
||
session_id = getattr(agent, "session_id", None)
|
||
if not session_db or not session_id:
|
||
return
|
||
|
||
if str(getattr(agent, "platform", "") or "").lower() in _UNTITLED_PLATFORMS:
|
||
return
|
||
|
||
try:
|
||
from agent.message_content import flatten_message_text
|
||
from agent.title_generator import maybe_auto_title
|
||
|
||
# The turn's own user message, as text. Multimodal turns flatten to
|
||
# their text parts; an image-only turn yields "" and is skipped, since
|
||
# there is nothing to title from.
|
||
user_text = ""
|
||
for msg in reversed(messages or []):
|
||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||
user_text = flatten_message_text(msg.get("content")).strip()
|
||
break
|
||
if not user_text:
|
||
return
|
||
|
||
# The session row is created lazily on the first persist, which happens
|
||
# later in the turn. Force it now, or the title write matches zero rows
|
||
# and the session stays untitled for the whole turn anyway.
|
||
if not getattr(agent, "_session_db_created", False):
|
||
ensure = getattr(agent, "_ensure_db_session", None)
|
||
if callable(ensure):
|
||
ensure()
|
||
if not getattr(agent, "_session_db_created", False):
|
||
return
|
||
|
||
# Snapshot the runtime identity; the validator lets the background
|
||
# titler skip its LLM call if the user switches models before it fires
|
||
# (a stale request would reload an unloaded Ollama model, #19027).
|
||
_model = getattr(agent, "model", None)
|
||
_provider = getattr(agent, "provider", None)
|
||
|
||
maybe_auto_title(
|
||
session_db,
|
||
session_id,
|
||
user_text,
|
||
conversation_history=messages,
|
||
failure_callback=(
|
||
getattr(agent, "_title_failure_callback", None)
|
||
or getattr(agent, "_emit_auxiliary_failure", None)
|
||
),
|
||
main_runtime={
|
||
"model": _model,
|
||
"provider": _provider,
|
||
"base_url": getattr(agent, "base_url", None),
|
||
"api_key": getattr(agent, "api_key", None),
|
||
"api_mode": getattr(agent, "api_mode", None),
|
||
},
|
||
title_callback=getattr(agent, "_on_session_title", None),
|
||
runtime_validator=lambda: (
|
||
getattr(agent, "model", None) == _model
|
||
and getattr(agent, "provider", None) == _provider
|
||
),
|
||
)
|
||
except Exception:
|
||
logger.debug("Turn-start auto-title dispatch failed", exc_info=True)
|
||
|
||
|
||
def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> int:
|
||
"""Locate this turn's user message after compaction rebuilt ``messages``.
|
||
|
||
Compression replaces list entries with fresh copies (and may append a
|
||
todo-snapshot user message or a restored user turn AFTER the surviving
|
||
copy of the current turn's message), so a pre-compression index is
|
||
meaningless. Prefer the LAST user message whose content exactly matches
|
||
this turn's text — the surviving copy in the common case — so the
|
||
injection stamp and the #48677 persist override can't land on a
|
||
todo-snapshot or historical row. Fall back to the last *user-originated*
|
||
turn when no exact match survives (merge-summary-into-tail rewrites the
|
||
content but the trackers still need a live anchor). Compaction handoffs
|
||
must never become the fallback anchor (#80622) — they are reference-only
|
||
scaffolding, not the active ask. Returns -1 when the list has no
|
||
user-originated message at all.
|
||
"""
|
||
from agent.context_compressor import user_originated_turn_view
|
||
|
||
fallback = -1
|
||
for i in range(len(messages) - 1, -1, -1):
|
||
msg = messages[i]
|
||
if not (isinstance(msg, dict) and msg.get("role") == "user"):
|
||
continue
|
||
# Typed synthetic current events still need their physical persistence
|
||
# anchor when their raw content is unchanged. They are not eligible
|
||
# for the human-only fallback below.
|
||
if msg.get("content") == user_message:
|
||
return i
|
||
live_view = user_originated_turn_view(msg)
|
||
if live_view is None:
|
||
continue
|
||
if live_view.get("content") == user_message:
|
||
return i
|
||
# Prefer a real human turn over a synthetic handoff / continuation
|
||
# marker when the exact content was rewritten by merge-into-tail.
|
||
if fallback < 0:
|
||
fallback = i
|
||
return fallback
|
||
|
||
|
||
def compression_made_progress(
|
||
orig_len: int, new_len: int, orig_tokens: int, new_tokens: int
|
||
) -> bool:
|
||
"""Return ``True`` if a compression pass materially reduced the request.
|
||
|
||
Compression can succeed by summarising message contents — reducing the
|
||
estimated request token count — without reducing the message row
|
||
count. Treating row count as the sole progress signal false-positives
|
||
on size-only wins and surfaces a misleading "Cannot compress further"
|
||
failure even when post-compression tokens are well below the model
|
||
context window. See issue #39548 for an observed case: 220 → 220
|
||
messages, ~288k → ~183k tokens on a 1M-context model still triggered
|
||
auto-reset.
|
||
|
||
The token reduction must be *material* (>5%) to count as progress — the
|
||
same floor the overflow-handler retry path uses (conversation_loop.py,
|
||
#39550) — so a sub-5% wobble doesn't keep the multi-pass loop spinning.
|
||
"""
|
||
if new_len < orig_len:
|
||
return True
|
||
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
|
||
|
||
|
||
# Back-compat alias: this predicate was module-private until the gateway's
|
||
# session-hygiene recovery gate needed the same semantics (#79624). Keeping the
|
||
# old name bound means existing callers and any test that patches
|
||
# ``_compression_made_progress`` continue to work unchanged.
|
||
_compression_made_progress = compression_made_progress
|
||
|
||
|
||
class PreflightCompressionTimedOut(RuntimeError):
|
||
"""Raised when an oversized turn cannot safely finish preflight."""
|
||
|
||
|
||
def _fail_closed_after_preflight_timeout(agent, request_tokens: int) -> None:
|
||
"""Stop an oversized turn instead of sending its unchanged provider payload."""
|
||
from agent.conversation_compression import context_compression_timed_out
|
||
|
||
if not context_compression_timed_out(agent):
|
||
return
|
||
raise PreflightCompressionTimedOut(
|
||
"Context compression timed out before it could commit while the request "
|
||
f"was still approximately {request_tokens:,} tokens. The provider call "
|
||
"was not sent. Run /compress and wait for it to finish, then retry."
|
||
)
|
||
|
||
|
||
def _review_fork_first_request_pending(agent: Any) -> bool:
|
||
"""Whether a detached review fork has yet to send its first provider request.
|
||
|
||
The background-review fork (issue #93057) replays the parent's FULL
|
||
snapshot on its first provider request as a warm prompt-cache read
|
||
(same-model cache parity). Compaction must not rewrite the snapshot
|
||
before that first request goes out — a compacted transcript would miss
|
||
the parent's cached prefix and turn a cheap cached replay into a cold
|
||
over-threshold write. Once the first provider response has arrived the
|
||
fork's tool loop is its own context, and both compression gates resume.
|
||
Dormant for every agent without the attribute.
|
||
"""
|
||
return bool(
|
||
getattr(agent, "_review_defer_compaction_before_first_response", False)
|
||
and not getattr(agent, "_turn_received_provider_response", False)
|
||
)
|
||
|
||
|
||
def _compression_warrants_another_preflight_pass(
|
||
orig_tokens: int, new_tokens: int, threshold_tokens: int
|
||
) -> bool:
|
||
"""Whether an over-threshold request merits another immediate summary.
|
||
|
||
Row-count progress is enough to prove that a compression boundary was real,
|
||
but not enough to justify another expensive pass before trying the provider.
|
||
Continue only when the request remains over threshold *and* the previous pass
|
||
materially reduced its estimated token pressure (>5%).
|
||
"""
|
||
return (
|
||
new_tokens >= threshold_tokens
|
||
and orig_tokens > 0
|
||
and new_tokens < orig_tokens * 0.95
|
||
)
|
||
|
||
|
||
def _should_run_preflight_estimate(
|
||
messages: List[Dict[str, Any]],
|
||
protect_first_n: int,
|
||
protect_last_n: int,
|
||
threshold_tokens: int,
|
||
) -> bool:
|
||
"""Cheap gate for the (expensive) full preflight token estimate.
|
||
|
||
Returns ``True`` when either:
|
||
(a) message count exceeds the protected ranges (the historical gate), or
|
||
(b) a cheap char-based estimate already crosses the configured threshold
|
||
— the few-but-huge case from issue #27405 that the count-only gate
|
||
would silently skip (a handful of very large messages never trips
|
||
the count condition, so compression was never attempted and the
|
||
turn hit a hard context-overflow error).
|
||
|
||
Branch (b) uses ``estimate_messages_tokens_rough`` (the shared char-based
|
||
estimator) so a single large base64 image isn't mistaken for ~250K tokens.
|
||
It intentionally undercounts vs. the full request estimate — it omits the
|
||
system prompt and tool schemas — because it is only a *hint* deciding
|
||
whether to pay for the authoritative ``estimate_request_tokens_rough``,
|
||
which (together with ``should_compress``) makes the real decision.
|
||
"""
|
||
if len(messages) > protect_first_n + protect_last_n + 1:
|
||
return True
|
||
return estimate_messages_tokens_rough(messages) >= threshold_tokens
|
||
|
||
|
||
def _should_idle_compact(
|
||
*,
|
||
enabled: bool,
|
||
idle_after_seconds: int,
|
||
idle_gap_seconds: float,
|
||
tokens: int,
|
||
floor_tokens: int,
|
||
cooldown_active: bool,
|
||
last_compaction_tokens: int = 0,
|
||
) -> bool:
|
||
"""Decide whether an idle-triggered compaction should run this turn.
|
||
|
||
Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It
|
||
fires when a session resumes after a wall-clock gap of at least
|
||
``idle_after_seconds`` since its last activity, so a long-lived thread
|
||
that is paused and later resumed compacts its accumulated history up
|
||
front instead of re-reading it on every subsequent turn.
|
||
|
||
It is orthogonal to the token-threshold trigger: it does NOT require the
|
||
context to exceed ``threshold_tokens``. It still skips work when the
|
||
context is at or below ``floor_tokens`` (the size compaction would reduce
|
||
*to*), so a small idle thread never pays for a summarisation that saves
|
||
nothing, and it defers to an active compression-failure cooldown.
|
||
|
||
``floor_tokens`` alone is a *theoretical* target (``threshold_tokens ×
|
||
summary_target_ratio``) that a real pass routinely misses: the system
|
||
prompt, the tool schemas and the protected head/tail are an
|
||
incompressible floor. A session that compacted to well above that target
|
||
therefore stays above it forever, so every later idle resume re-runs a
|
||
full summarisation over a transcript that has not grown — minutes of
|
||
silently blocked prompt on a slow route, reclaiming nothing (#97239).
|
||
|
||
``last_compaction_tokens`` is what the previous pass on this session
|
||
actually produced (``ContextCompressor.last_compression_rough_tokens``,
|
||
the same ``estimate_request_tokens_rough`` shape as ``tokens``). When it
|
||
is known, require the transcript to have accumulated at least one
|
||
``floor_tokens`` worth of *new* content on top of it before paying for
|
||
another pass. ``0`` — no compaction recorded yet, or the counter reset by
|
||
a rebind/recalibration — keeps the original floor semantics exactly, so
|
||
the first idle compaction of any session is unaffected.
|
||
|
||
Pure predicate so the policy is unit-testable without a live agent.
|
||
"""
|
||
if not enabled or idle_after_seconds <= 0:
|
||
return False
|
||
if idle_gap_seconds < idle_after_seconds:
|
||
return False
|
||
if cooldown_active:
|
||
return False
|
||
effective_floor = floor_tokens
|
||
if last_compaction_tokens > 0:
|
||
effective_floor = max(effective_floor, last_compaction_tokens + floor_tokens)
|
||
return tokens > effective_floor
|
||
|
||
|
||
@dataclass
|
||
class TurnContext:
|
||
"""Values produced by the turn prologue and consumed by the turn loop."""
|
||
|
||
# Sanitized inbound message (surrogates stripped).
|
||
user_message: str
|
||
# Clean message preserved for transcripts / memory queries (no nudge injection).
|
||
original_user_message: Any
|
||
# Working message list for this turn (loop appends to it).
|
||
messages: List[Dict[str, Any]]
|
||
# May be reset to None by preflight compression (new session created).
|
||
conversation_history: Optional[List[Dict[str, Any]]]
|
||
# Cached system prompt active for this turn (may be rebuilt by compression).
|
||
active_system_prompt: Optional[str]
|
||
# Task / turn identifiers.
|
||
effective_task_id: str
|
||
turn_id: str
|
||
# Index of the current user turn within ``messages``.
|
||
current_turn_user_idx: int
|
||
# Whether the post-turn memory review should fire.
|
||
should_review_memory: bool = False
|
||
# Context contributed by ``pre_llm_call`` plugins (appended to user message).
|
||
plugin_user_context: str = ""
|
||
# External-memory prefetch result, reused across loop iterations.
|
||
ext_prefetch_cache: str = ""
|
||
# Turn-start preflight already proved an immediate retry ineffective.
|
||
preflight_compression_blocked: bool = False
|
||
|
||
|
||
def build_turn_context(
|
||
agent,
|
||
user_message: Any,
|
||
system_message: Optional[str],
|
||
conversation_history: Optional[List[Dict[str, Any]]],
|
||
task_id: Optional[str],
|
||
stream_callback,
|
||
persist_user_message: Optional[Any],
|
||
persist_user_timestamp: Optional[float] = None,
|
||
persist_user_platform_id: Optional[str] = None,
|
||
*,
|
||
persist_user_display_kind: Optional[str] = None,
|
||
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
|
||
restore_or_build_system_prompt,
|
||
install_safe_stdio,
|
||
sanitize_surrogates,
|
||
summarize_user_message_for_log,
|
||
set_session_context,
|
||
set_current_write_origin,
|
||
ra,
|
||
moa_active: bool = False,
|
||
) -> TurnContext:
|
||
"""Run the once-per-turn setup and return the loop's input context.
|
||
|
||
The callables/helpers the original prologue referenced from the
|
||
``conversation_loop`` module are passed in explicitly to keep this module
|
||
free of an import cycle with ``agent.conversation_loop``.
|
||
"""
|
||
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
|
||
install_safe_stdio()
|
||
|
||
# Recover a session rotated by another path before binding log/turn ids or
|
||
# copying client-supplied history. Everything in this turn must consistently
|
||
# belong to the canonical child, including observability metadata.
|
||
recovered_history = recover_rotated_compression_session(agent)
|
||
if recovered_history is not None:
|
||
conversation_history = recovered_history
|
||
|
||
# NOTE: the DB session row is created later, AFTER the system prompt is
|
||
# restored/built (see _ensure_db_session() below the system-prompt block).
|
||
# Creating it here — before _cached_system_prompt is populated — inserts a
|
||
# row with system_prompt=NULL on a fresh API/gateway agent that carries
|
||
# client-managed history, which then trips the "stored system prompt is
|
||
# null; rebuilding from scratch" warning and a needless first-turn prefix
|
||
# cache miss. (Issue #45499.)
|
||
|
||
# Tag log records on this thread with the session ID for ``hermes logs``.
|
||
set_session_context(agent.session_id)
|
||
|
||
# Bind the skill write-origin ContextVar for this thread.
|
||
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
|
||
|
||
# Restore the primary runtime if the previous turn activated fallback.
|
||
agent._restore_primary_runtime()
|
||
|
||
# Tell auxiliary_client what the live main provider/model are for this turn
|
||
# after primary restoration has settled the runtime.
|
||
try:
|
||
from agent.auxiliary_client import set_runtime_main
|
||
from agent.prompt_cache_scope import resolve_prompt_cache_scope_safe
|
||
# Rotation-stable prompt-cache scope. Memoized per segment on the
|
||
# agent, so this is a DB walk at most once per segment — except a
|
||
# brand-new session whose row lands later in turn setup
|
||
# (_ensure_db_session); that first turn falls back to the physical
|
||
# id here and the first build_api_kwargs re-resolves. Stays valid
|
||
# through a mid-turn compression rotation because the lineage root
|
||
# is by definition rotation-invariant (#79017). Resolved with the
|
||
# never-raising variant OUTSIDE the argument list, so a resolution
|
||
# failure can only lose the scope — never the whole runtime binding.
|
||
_cache_scope = resolve_prompt_cache_scope_safe(agent) or ""
|
||
set_runtime_main(
|
||
getattr(agent, "provider", "") or "",
|
||
getattr(agent, "model", "") or "",
|
||
requested_provider=getattr(agent, "requested_provider", "") or "",
|
||
base_url=getattr(agent, "base_url", "") or "",
|
||
api_key=getattr(agent, "api_key", "") or "",
|
||
api_mode=getattr(agent, "api_mode", "") or "",
|
||
auth_mode=getattr(agent, "auth_mode", "") or "",
|
||
session_id=getattr(agent, "session_id", "") or "",
|
||
cache_scope=_cache_scope,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Between-turns MCP refresh: an MCP server that finished connecting since
|
||
# the previous turn (slow HTTP/OAuth servers routinely take 2-6s on a cold
|
||
# connect, missing the bounded startup wait) lands in THIS turn's tool
|
||
# snapshot. Timing is cache-safe by construction: it runs in the per-turn
|
||
# prologue, before this turn's first API call assembles ``tools=``, so it
|
||
# never mutates the prefix of an in-flight turn. ``preserve_prefix`` makes
|
||
# the *content* cache-safe too (#100336): a plain rebuild re-derives the
|
||
# array from live availability, so a flapping ``check_fn`` silently drops a
|
||
# tool and a late arrival splices into sorted position — either one forks
|
||
# the tool block and re-prefills the whole history behind it, every turn it
|
||
# happens. With the flag the live order is authoritative and the array
|
||
# only ever grows. No-op when no MCP servers are registered (the common
|
||
# case, gated by the cheap ``has_registered_mcp_tools`` check) or when the
|
||
# tool set is unchanged (``refresh_agent_mcp_tools`` diffs by name and
|
||
# leaves the snapshot untouched on no-change).
|
||
try:
|
||
if not getattr(agent, "_skip_mcp_refresh", False):
|
||
# Import-cost gate: ``tools.mcp_tool`` pulls in the whole ``mcp``
|
||
# package (~0.4s measured) even when the user has zero MCP servers
|
||
# configured. MCP tools can only be registered by code that has
|
||
# already imported ``tools.mcp_tool`` (discovery, /reload-mcp,
|
||
# late-binding refresh) — so if it isn't in sys.modules yet, there
|
||
# is nothing to refresh and the import can be skipped outright.
|
||
# This keeps the no-MCP first turn off the heavy import path
|
||
# without changing behavior for MCP users.
|
||
import sys as _sys
|
||
if "tools.mcp_tool" in _sys.modules:
|
||
from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools
|
||
if has_registered_mcp_tools():
|
||
refresh_agent_mcp_tools(
|
||
agent, quiet_mode=True, preserve_prefix=True,
|
||
)
|
||
except Exception:
|
||
logger.debug("between-turns MCP tool refresh skipped", exc_info=True)
|
||
|
||
# Sanitize surrogate characters from user input.
|
||
if isinstance(user_message, str):
|
||
user_message = sanitize_surrogates(user_message)
|
||
if isinstance(persist_user_message, str):
|
||
persist_user_message = sanitize_surrogates(persist_user_message)
|
||
|
||
# Store stream callback for _interruptible_api_call to pick up.
|
||
agent._stream_callback = stream_callback
|
||
agent._persist_user_message_idx = None
|
||
agent._persist_user_message_override = persist_user_message
|
||
agent._persist_user_message_timestamp = persist_user_timestamp
|
||
agent._persist_user_message_platform_id = persist_user_platform_id
|
||
# Generate unique task_id if not provided to isolate VMs between tasks.
|
||
effective_task_id = task_id or str(uuid.uuid4())
|
||
agent._current_task_id = effective_task_id
|
||
turn_id = str(getattr(agent, "_relay_pending_turn_id", "") or "")
|
||
if not turn_id:
|
||
turn_id = (
|
||
f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
|
||
)
|
||
agent._relay_pending_turn_id = None
|
||
agent._current_turn_id = turn_id
|
||
agent._current_api_request_id = ""
|
||
# Tripwire: warn (with both turn ids) when this turn starts before the
|
||
# previous turn's turn-end persist — concurrent turns on one session
|
||
# interleave transcript writes. Cleared in _persist_session.
|
||
from agent.agent_runtime_helpers import note_turn_start
|
||
note_turn_start(agent, turn_id)
|
||
|
||
# Reset retry counters and iteration budget at the start of each turn.
|
||
agent._invalid_tool_retries = 0
|
||
agent._invalid_json_retries = 0
|
||
agent._empty_content_retries = 0
|
||
agent._incomplete_scratchpad_retries = 0
|
||
agent._codex_incomplete_retries = 0
|
||
agent._thinking_prefill_retries = 0
|
||
agent._post_tool_empty_retried = False
|
||
agent._last_content_with_tools = None
|
||
agent._last_content_tools_all_housekeeping = False
|
||
agent._mute_post_response = False
|
||
agent._unicode_sanitization_passes = 0
|
||
agent._tool_guardrails.reset_for_turn()
|
||
agent._tool_guardrail_halt_decision = None
|
||
_reset_consol = getattr(agent._memory_store, "reset_consolidation_failures", None)
|
||
if callable(_reset_consol):
|
||
_reset_consol()
|
||
agent._vision_supported = True
|
||
|
||
# Pre-turn connection health check: clean up dead TCP connections.
|
||
if agent.api_mode != "anthropic_messages":
|
||
try:
|
||
if agent._cleanup_dead_connections():
|
||
agent._emit_status(
|
||
"🔌 Detected stale connections from a previous provider "
|
||
"issue — cleaned up automatically. Proceeding with fresh "
|
||
"connection."
|
||
)
|
||
except Exception:
|
||
pass
|
||
# Replay compression warning through status_callback for gateway platforms.
|
||
if agent._compression_warning:
|
||
agent._replay_compression_warning()
|
||
agent._compression_warning = None # send once
|
||
|
||
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
|
||
agent.iteration_budget = IterationBudget(agent.max_iterations)
|
||
|
||
# Wall-clock run budget: per-run_conversation clock. Only stamped when a
|
||
# budget is configured so the default path stays clock-free; the wrap-up
|
||
# latch resets each turn (one notice per run, not per session).
|
||
if getattr(agent, "run_budget_seconds", None):
|
||
agent._run_budget_started_at = time.time()
|
||
else:
|
||
agent._run_budget_started_at = None
|
||
agent._run_budget_wrapup_injected = False
|
||
|
||
# Log conversation turn start for debugging/observability.
|
||
_preview_text = summarize_user_message_for_log(user_message)
|
||
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
|
||
_msg_preview = _msg_preview.replace("\n", " ")
|
||
logger.info(
|
||
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
|
||
agent.session_id or "none", agent.model, agent.provider or "unknown",
|
||
agent.platform or "unknown", len(conversation_history or []),
|
||
_msg_preview,
|
||
)
|
||
|
||
# Initialize conversation (copy to avoid mutating the caller's list).
|
||
messages = list(conversation_history) if conversation_history else []
|
||
|
||
# The CLI may already have staged this input outside the history passed to
|
||
# ``run_conversation``. Reuse it only when its clean transcript text matches
|
||
# this turn; a stale handoff from a failed prior turn must not replace a
|
||
# later, different user input. Voice turns compare against their explicit
|
||
# clean persistence override rather than the API-only prefixed payload.
|
||
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
|
||
expected_persist_content = (
|
||
persist_user_message if persist_user_message is not None else user_message
|
||
)
|
||
if (
|
||
isinstance(pending_cli_message, dict)
|
||
and pending_cli_message.get("content") == expected_persist_content
|
||
):
|
||
user_msg = pending_cli_message
|
||
# The CLI-staged value is the clean transcript text. Restore the
|
||
# API-facing variant (for example, a voice-mode prefix) while retaining
|
||
# the same dict and any close-path durable marker.
|
||
user_msg["content"] = user_message
|
||
else:
|
||
user_msg = stamp_message_timestamp(
|
||
{"role": "user", "content": user_message},
|
||
timestamp=persist_user_timestamp,
|
||
)
|
||
if isinstance(pending_cli_message, dict):
|
||
agent._pending_cli_user_message = None
|
||
# CLI input is stamped when staged. Gateway input may carry the platform
|
||
# event time. Preserve either value and cover any legacy unstamped handoff.
|
||
stamp_message_timestamp(user_msg, timestamp=persist_user_timestamp)
|
||
|
||
# Hydrate todo store from conversation history.
|
||
if conversation_history and not agent._todo_store.has_items():
|
||
agent._hydrate_todo_store(conversation_history)
|
||
|
||
# Hydrate per-session nudge counters from persisted history (issue #22357).
|
||
if conversation_history and agent._user_turn_count == 0:
|
||
prior_user_turns = sum(
|
||
1 for m in conversation_history if m.get("role") == "user"
|
||
)
|
||
if prior_user_turns > 0:
|
||
agent._user_turn_count = prior_user_turns
|
||
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
|
||
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
|
||
|
||
# Add the current user message after the prompt/session setup has made
|
||
# close persistence safe. The handoff above preserves any marker already
|
||
# stamped by an earlier close flush.
|
||
#
|
||
# A synthesized turn (auto-continue recovery note, delegation completion)
|
||
# declares how it should READ in a transcript. Stamp that on the live
|
||
# message so the crash persist below writes the row already typed. Typing
|
||
# it after the turn instead leaves the row untyped for the whole run — and
|
||
# forever if the turn crashes — so the raw system note paints as a user
|
||
# bubble. The model still receives role/content unchanged; the api_messages
|
||
# build strips both fields from every outgoing copy.
|
||
if persist_user_display_kind:
|
||
user_msg["display_kind"] = persist_user_display_kind
|
||
if persist_user_display_metadata:
|
||
user_msg["display_metadata"] = persist_user_display_metadata
|
||
|
||
# Stamp the platform-side message id (e.g. the Discord/Telegram message id)
|
||
# as metadata on the user turn so it survives the early crash-resilience
|
||
# persist below (the turn-start flush). Load-bearing for restart
|
||
# drain-window recovery: a recovery pass dedups via
|
||
# ``has_platform_message_id`` against this row.
|
||
if persist_user_platform_id is not None:
|
||
user_msg["platform_message_id"] = persist_user_platform_id
|
||
append_message(messages, user_msg)
|
||
current_turn_user_idx = len(messages) - 1
|
||
agent._persist_user_message_idx = current_turn_user_idx
|
||
|
||
# Track user turns for memory flush and periodic nudge logic.
|
||
agent._user_turn_count += 1
|
||
# Copilot x-initiator: the first API call of this user turn is
|
||
# user-initiated; tool-loop follow-ups revert to "agent" (#3040).
|
||
agent._is_user_initiated_turn = True
|
||
|
||
# Reset the streaming context scrubber at the top of each turn.
|
||
scrubber = getattr(agent, "_stream_context_scrubber", None)
|
||
if scrubber is not None:
|
||
scrubber.reset()
|
||
# Reset the think scrubber for the same reason.
|
||
think_scrubber = getattr(agent, "_stream_think_scrubber", None)
|
||
if think_scrubber is not None:
|
||
think_scrubber.reset()
|
||
|
||
# Preserve the original user message (no nudge injection).
|
||
original_user_message = persist_user_message if persist_user_message is not None else user_message
|
||
|
||
# Track memory nudge trigger (turn-based, checked here).
|
||
should_review_memory = False
|
||
if (agent._memory_nudge_interval > 0
|
||
and "memory" in agent.valid_tool_names
|
||
and agent._memory_store):
|
||
agent._turns_since_memory += 1
|
||
if agent._turns_since_memory >= agent._memory_nudge_interval:
|
||
should_review_memory = True
|
||
agent._turns_since_memory = 0
|
||
|
||
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
|
||
# and notify the host so it can play hearts. Token-free, never touches the
|
||
# conversation, and never fatal — a purely optional UI beat.
|
||
reaction_callback = getattr(agent, "reaction_callback", None)
|
||
if reaction_callback is not None:
|
||
try:
|
||
from agent.reactions import detect_reaction
|
||
|
||
kind = detect_reaction(original_user_message)
|
||
if kind:
|
||
reaction_callback(kind)
|
||
except Exception:
|
||
pass
|
||
|
||
if not agent.quiet_mode:
|
||
_print_preview = summarize_user_message_for_log(user_message)
|
||
agent._safe_print(
|
||
f"💬 Starting conversation: '{_print_preview[:60]}"
|
||
f"{'...' if len(_print_preview) > 60 else ''}'"
|
||
)
|
||
|
||
# ── System prompt (cached per session for prefix caching) ──
|
||
if agent._cached_system_prompt is None:
|
||
restore_or_build_system_prompt(agent, system_message, conversation_history)
|
||
|
||
active_system_prompt = agent._cached_system_prompt
|
||
|
||
# Bot Mode DM tool — injected ONLY into a bot's canonical "Bot Chat"
|
||
# session on Bot-Mode-managed installs (same gate as the protocol
|
||
# section above). The gate is stable for a session's lifetime, so the
|
||
# tool list is byte-identical every turn: prompt-cache safe. Every
|
||
# other session (CLI, gateway chats, group-room member sessions, cron,
|
||
# subagents) fails the gate and never sees the schema.
|
||
try:
|
||
from tools.bot_mode_dm import ensure_message_agent_tool
|
||
|
||
ensure_message_agent_tool(agent)
|
||
except Exception:
|
||
logger.debug("message_agent injection skipped", exc_info=True)
|
||
|
||
# Create the DB session row now that _cached_system_prompt is populated, so
|
||
# the persisted snapshot is written non-NULL on the first turn (Issue
|
||
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
|
||
# Must run BEFORE preflight compression: in-place compaction inserts
|
||
# message rows referencing this session (archive_and_compact), and
|
||
# rotation creates a child with parent_session_id pointing at it — with
|
||
# PRAGMA foreign_keys=ON, a missing parent row fails both INSERTs on a
|
||
# fresh oversized first turn. The user-turn crash persist itself runs
|
||
# LATER (after memory prefetch / pre_llm_call), so the row is written
|
||
# once with its final api_content — both steps take the same per-agent
|
||
# persist lock as CLI close persistence.
|
||
persist_lock = getattr(agent, "_session_persist_lock", None)
|
||
try:
|
||
if persist_lock is None:
|
||
agent._ensure_db_session()
|
||
else:
|
||
with persist_lock:
|
||
agent._ensure_db_session()
|
||
except Exception:
|
||
logger.warning(
|
||
"Turn-start session row creation failed for session=%s",
|
||
agent.session_id or "none",
|
||
exc_info=True,
|
||
)
|
||
finally:
|
||
# Clear the staged CLI input eagerly (as the pre-refactor code did)
|
||
# so a crash in preflight compression — which runs between this row
|
||
# create and the late crash-persist below — doesn't leave a stale
|
||
# _pending_cli_user_message that the next turn would mistake for a
|
||
# fresh staged input.
|
||
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
|
||
agent._pending_cli_user_message = None
|
||
|
||
# ── Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``) ──
|
||
# When a session resumes after a long idle gap, compact the accumulated
|
||
# history up front so the rest of the conversation does not keep re-reading
|
||
# a large stale context on every turn. This fires on elapsed wall-clock time
|
||
# rather than size, so it complements (does not replace) the token-threshold
|
||
# preflight below. ``_last_activity_ts`` is the last time this turn loop did
|
||
# work; nothing has touched it yet this turn, so it measures the gap since
|
||
# the previous turn finished. The cheap gap pre-check gates the (more
|
||
# expensive) token estimate, mirroring ``_should_run_preflight_estimate``.
|
||
_idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0)
|
||
if agent.compression_enabled and _idle_after > 0 and messages:
|
||
_idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time())
|
||
if _idle_gap >= _idle_after:
|
||
_compressor = agent.context_compressor
|
||
# Route-aware pressure (#96995/#97602 class): on a compacted
|
||
# native-Codex session the generic durable-history figure
|
||
# overstates the wire by orders of magnitude and would fire an
|
||
# idle compaction the next request never needed. Reuse the
|
||
# preflight estimator (anchor → native pruned → generic).
|
||
_idle_tokens = _preflight_request_tokens(
|
||
agent,
|
||
messages,
|
||
active_system_prompt or "",
|
||
)
|
||
# Post-compression target size: don't summarise a thread already
|
||
# below what compaction would reduce it to.
|
||
_idle_floor = int(
|
||
_compressor.threshold_tokens * _compressor.summary_target_ratio
|
||
)
|
||
_idle_cooldown = getattr(
|
||
_compressor, "get_active_compression_failure_cooldown", lambda: None
|
||
)()
|
||
# What the previous pass on this session actually produced — the
|
||
# honest floor, versus the theoretical ``_idle_floor`` above. Type
|
||
# pin: minimal compressor doubles (SimpleNamespace / MagicMock)
|
||
# expose truthy non-ints here, and only a real int may raise the
|
||
# floor. Anything else falls back to 0 = original semantics.
|
||
_idle_last_compaction = getattr(
|
||
_compressor, "last_compression_rough_tokens", 0
|
||
)
|
||
if not isinstance(_idle_last_compaction, int) or isinstance(
|
||
_idle_last_compaction, bool
|
||
):
|
||
_idle_last_compaction = 0
|
||
if _should_idle_compact(
|
||
enabled=agent.compression_enabled,
|
||
idle_after_seconds=_idle_after,
|
||
idle_gap_seconds=_idle_gap,
|
||
tokens=_idle_tokens,
|
||
floor_tokens=_idle_floor,
|
||
cooldown_active=bool(_idle_cooldown),
|
||
last_compaction_tokens=_idle_last_compaction,
|
||
):
|
||
logger.info(
|
||
"Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor "
|
||
"(last compaction produced ~%s) (session %s)",
|
||
int(_idle_gap),
|
||
_idle_after,
|
||
f"{_idle_tokens:,}",
|
||
f"{_idle_floor:,}",
|
||
f"{_idle_last_compaction:,}" if _idle_last_compaction > 0 else "n/a",
|
||
agent.session_id or "none",
|
||
)
|
||
_idle_status = automatic_compaction_status_message(
|
||
_compressor,
|
||
phase="idle",
|
||
default_message=IDLE_COMPACTION_STATUS_TEMPLATE.format(
|
||
idle_seconds=int(_idle_gap), tokens=_idle_tokens
|
||
),
|
||
approx_tokens=_idle_tokens,
|
||
idle_seconds=int(_idle_gap),
|
||
model=agent.model,
|
||
)
|
||
if _idle_status:
|
||
agent._emit_status(_idle_status)
|
||
_idle_input = messages
|
||
messages, active_system_prompt = agent._compress_context(
|
||
messages, system_message, approx_tokens=_idle_tokens,
|
||
task_id=effective_task_id,
|
||
)
|
||
# ``_compress_context`` returns the INPUT list object when it
|
||
# skips (per-session lock held by another path, failure
|
||
# cooldown, anti-thrash breaker, codex-native routing). Only
|
||
# re-baseline + re-anchor after a real compaction — a skip
|
||
# must leave the turn's flush baseline and user-message index
|
||
# untouched.
|
||
if messages is not _idle_input:
|
||
conversation_history = conversation_history_after_compression(
|
||
agent, messages, conversation_history
|
||
)
|
||
# Compaction rebuilt the list, so the index of this turn's
|
||
# just-appended user message is stale — re-anchor it the
|
||
# same way the preflight path does below.
|
||
current_turn_user_idx = reanchor_current_turn_user_idx(
|
||
messages, user_message
|
||
)
|
||
agent._persist_user_message_idx = current_turn_user_idx
|
||
|
||
# ── Preflight context compression ──
|
||
# Gate the (expensive) full token estimate behind a cheap pre-check.
|
||
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
|
||
# issue #27405 (a few very large messages slipping past the count gate).
|
||
_preflight_compressed = False
|
||
_preflight_compression_blocked = False
|
||
agent._turn_received_provider_response = False
|
||
agent._turn_preflight_display_snapshot = None
|
||
if (
|
||
agent.compression_enabled
|
||
and not _review_fork_first_request_pending(agent)
|
||
and _should_run_preflight_estimate(
|
||
messages,
|
||
agent.context_compressor.protect_first_n,
|
||
agent.context_compressor.protect_last_n,
|
||
agent.context_compressor.threshold_tokens,
|
||
)
|
||
):
|
||
_preflight_tokens = _preflight_request_tokens(
|
||
agent,
|
||
messages,
|
||
active_system_prompt or "",
|
||
)
|
||
_compressor = agent.context_compressor
|
||
# getattr guard: minimal compressor doubles (SimpleNamespace in the
|
||
# engine-preflight tests) and plugin context engines lack this
|
||
# ContextCompressor-only method — absence means no snapshot, and the
|
||
# finalizer's rollback stays disarmed for the turn (display-only).
|
||
_snapshot_fn = getattr(
|
||
_compressor, "snapshot_preflight_display_tokens", None
|
||
)
|
||
if callable(_snapshot_fn):
|
||
_snapshot_val = _snapshot_fn()
|
||
# Type pin: MagicMock compressors return truthy Mock objects —
|
||
# only a real int snapshot may arm the interrupted-turn rollback.
|
||
if isinstance(_snapshot_val, int) and not isinstance(
|
||
_snapshot_val, bool
|
||
):
|
||
agent._turn_preflight_display_snapshot = _snapshot_val
|
||
_defer_preflight = getattr(
|
||
_compressor,
|
||
"should_defer_preflight_to_real_usage",
|
||
lambda _tokens: False,
|
||
)
|
||
_preflight_deferred = _defer_preflight(_preflight_tokens)
|
||
# Codex app-server threads are compacted by the codex agent itself;
|
||
# Hermes only initiates compaction in "hermes" mode (#36801).
|
||
_codex_native_auto = (
|
||
getattr(agent, "api_mode", None) == "codex_app_server"
|
||
and str(
|
||
getattr(
|
||
agent,
|
||
"codex_app_server_auto_compaction",
|
||
"native",
|
||
)
|
||
or "native"
|
||
).lower()
|
||
in {"native", "off"}
|
||
)
|
||
|
||
if not _preflight_deferred:
|
||
# Display-only seed (see
|
||
# ContextCompressor.maybe_seed_preflight_display_tokens): a real
|
||
# provider reading always wins over the rough estimate, and the
|
||
# -1 post-compression sentinel (#36718) stays protected. On
|
||
# usage-less responses the seed also feeds the tool-loop
|
||
# compression gate — the one live path where an inflated seed
|
||
# could push compression below the user threshold.
|
||
_maybe_seed = getattr(
|
||
_compressor, "maybe_seed_preflight_display_tokens", None
|
||
)
|
||
if callable(_maybe_seed):
|
||
_maybe_seed(_preflight_tokens)
|
||
|
||
_compression_cooldown = getattr(
|
||
_compressor,
|
||
"get_active_compression_failure_cooldown",
|
||
lambda: None,
|
||
)()
|
||
|
||
_should_compress_now = False
|
||
_compress_block_reason = None
|
||
if _preflight_deferred:
|
||
logger.info(
|
||
"Skipping preflight compression: rough estimate ~%s >= %s, "
|
||
"but last real provider prompt was %s after compression",
|
||
f"{_preflight_tokens:,}",
|
||
f"{_compressor.threshold_tokens:,}",
|
||
f"{_compressor.last_real_prompt_tokens:,}",
|
||
)
|
||
elif _compression_cooldown:
|
||
logger.info(
|
||
"Skipping preflight compression: same-session cooldown active "
|
||
"(~%s seconds remaining, session %s)",
|
||
int(_compression_cooldown.get("remaining_seconds", 0.0)),
|
||
agent.session_id or "none",
|
||
)
|
||
if _preflight_tokens >= _compressor.threshold_tokens:
|
||
# Context is over threshold but compression is blocked by the
|
||
# summary-LLM cooldown — surface a warning (see block below).
|
||
_cooldown_secs = _compression_cooldown.get("remaining_seconds", 0.0)
|
||
_compress_block_reason = f"cooldown:{_cooldown_secs:.0f}"
|
||
elif _codex_native_auto:
|
||
logger.info(
|
||
"Skipping Hermes preflight compression for codex app-server "
|
||
"(mode=%s); Hermes will not start thread compaction here.",
|
||
getattr(agent, "codex_app_server_auto_compaction", "native"),
|
||
)
|
||
else:
|
||
_should_compress_now = _compressor.should_compress(_preflight_tokens)
|
||
if not _should_compress_now:
|
||
# Context is over threshold but compression is blocked
|
||
# (summary-LLM cooldown or anti-thrashing). Ask should_compress_info
|
||
# for the human-readable reason so we can surface a warning below.
|
||
# getattr guard: minimal compressor doubles (SimpleNamespace in
|
||
# the engine-preflight tests) and older plugin engines lack the
|
||
# method — absence means no block reason, no warning.
|
||
_info = getattr(_compressor, "should_compress_info", None)
|
||
if callable(_info):
|
||
try:
|
||
_compress_block_reason = _info(_preflight_tokens)[1]
|
||
except Exception:
|
||
_compress_block_reason = None
|
||
if _should_compress_now:
|
||
# Managed local runtime: growing the window beats compressing —
|
||
# the ladder's design order (same seam as the conversation
|
||
# loop's pre-API gate; see _maybe_grow_local_window there).
|
||
try:
|
||
from agent.conversation_loop import _maybe_grow_local_window
|
||
|
||
_grown = _maybe_grow_local_window(
|
||
agent, _compressor, _preflight_tokens
|
||
)
|
||
except Exception:
|
||
_grown = None
|
||
if _grown:
|
||
_compressor.update_model(
|
||
agent.model,
|
||
_grown,
|
||
base_url=getattr(agent, "base_url", "") or "",
|
||
api_key=getattr(agent, "api_key", "") or "",
|
||
provider=getattr(agent, "provider", "") or "",
|
||
api_mode=getattr(agent, "api_mode", "") or "",
|
||
)
|
||
agent._buffer_status(
|
||
f"📈 Context window grown to {_grown // 1024}K "
|
||
f"(local model; conversation continues uncompressed)"
|
||
)
|
||
_should_compress_now = _compressor.should_compress(
|
||
_preflight_tokens
|
||
)
|
||
if _should_compress_now:
|
||
_preflight_compressed = True
|
||
# Compression is actually running (block cleared / was never
|
||
# blocked) — reset the dedup so a future blocked-over-threshold
|
||
# turn can warn again. Real session boundary.
|
||
# getattr guard: test doubles built via object.__new__ lack the
|
||
# method (gateway test-double pitfall) — treat absence as no-op.
|
||
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
|
||
if callable(_clear_warn):
|
||
_clear_warn()
|
||
logger.info(
|
||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||
f"{_preflight_tokens:,}",
|
||
f"{_compressor.threshold_tokens:,}",
|
||
agent.model,
|
||
f"{_compressor.context_length:,}",
|
||
)
|
||
_preflight_status = automatic_compaction_status_message(
|
||
_compressor,
|
||
phase="preflight",
|
||
default_message=PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
|
||
tokens=_preflight_tokens,
|
||
threshold=_compressor.threshold_tokens,
|
||
),
|
||
approx_tokens=_preflight_tokens,
|
||
threshold_tokens=_compressor.threshold_tokens,
|
||
context_length=_compressor.context_length,
|
||
model=agent.model,
|
||
)
|
||
if _preflight_status:
|
||
agent._emit_status(_preflight_status)
|
||
# Preflight passes honor the same configured per-turn cap
|
||
# (compression.max_attempts) as the loop's compression sites;
|
||
# default 3 preserves the prior hardcoded behavior.
|
||
_max_preflight_passes = max(
|
||
1, int(getattr(agent, "max_compression_attempts", 3) or 3)
|
||
)
|
||
for _pass in range(_max_preflight_passes):
|
||
_orig_len = len(messages)
|
||
_orig_tokens = _preflight_tokens
|
||
_preflight_input = messages
|
||
messages, active_system_prompt = agent._compress_context(
|
||
messages, system_message, approx_tokens=_preflight_tokens,
|
||
task_id=effective_task_id,
|
||
)
|
||
if (
|
||
messages is _preflight_input
|
||
and compression_skipped_due_to_lock(agent)
|
||
):
|
||
# #69870 lock-skip: another path holds this session's
|
||
# compression lock, so the pass no-oped. That is a
|
||
# temporary DEFER, not proof the transcript cannot
|
||
# compress — do NOT arm the insufficient-progress
|
||
# blocker (the loop's error handlers must keep their
|
||
# provider-proven retry budget) and stop preflight
|
||
# passes for this turn; the lock winner is shrinking
|
||
# the same session concurrently.
|
||
logger.info(
|
||
"Preflight compression deferred: compression lock "
|
||
"held by another path (session %s)",
|
||
agent.session_id or "none",
|
||
)
|
||
break
|
||
# Re-estimate now so size-only compression (same row count,
|
||
# lower token count — e.g. summarising tool outputs) is
|
||
# recognised as progress instead of being misread as
|
||
# "Cannot compress further". Fixes #39548.
|
||
_preflight_tokens = _preflight_request_tokens(
|
||
agent,
|
||
messages,
|
||
active_system_prompt or "",
|
||
)
|
||
if not _compression_made_progress(
|
||
_orig_len, len(messages), _orig_tokens, _preflight_tokens
|
||
):
|
||
_fail_closed_after_preflight_timeout(agent, _preflight_tokens)
|
||
_preflight_compression_blocked = True
|
||
break # Cannot compress further: neither rows nor tokens moved
|
||
conversation_history = conversation_history_after_compression(
|
||
agent, messages, conversation_history
|
||
)
|
||
agent._empty_content_retries = 0
|
||
agent._thinking_prefill_retries = 0
|
||
agent._last_content_with_tools = None
|
||
agent._last_content_tools_all_housekeeping = False
|
||
agent._mute_post_response = False
|
||
if not _compressor.should_compress(_preflight_tokens):
|
||
break
|
||
if not _compression_warrants_another_preflight_pass(
|
||
_orig_tokens,
|
||
_preflight_tokens,
|
||
_compressor.threshold_tokens,
|
||
):
|
||
_preflight_compression_blocked = True
|
||
logger.warning(
|
||
"Preflight compression made insufficient progress: "
|
||
"~%s -> ~%s request tokens; skipping additional passes",
|
||
f"{_orig_tokens:,}",
|
||
f"{_preflight_tokens:,}",
|
||
)
|
||
break
|
||
elif _compress_block_reason:
|
||
# Context is already over the compression threshold, but compression
|
||
# is blocked (summary LLM cooldown or anti-thrashing). Without a
|
||
# signal the session keeps growing until the model silently stops
|
||
# answering — the conversation hits the hard provider token limit
|
||
# with no explanation. Surface a deduped warning so the user can
|
||
# take action (/new or /compress) instead of hitting a silent hang.
|
||
agent._warn_context_overflow_blocked(
|
||
_compress_block_reason,
|
||
_preflight_tokens,
|
||
_compressor.threshold_tokens,
|
||
)
|
||
else:
|
||
# Sub-threshold and unblocked — allow the overflow warning to fire
|
||
# again next time the context is over threshold but blocked.
|
||
# getattr guard: test doubles built via object.__new__ lack the
|
||
# method (gateway test-double pitfall) — treat absence as no-op.
|
||
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
|
||
if callable(_clear_warn):
|
||
_clear_warn()
|
||
# Engine maintenance only when NO skip-branch fired: a failure
|
||
# cooldown, deferred estimate, or codex-native route must keep
|
||
# the engine hook un-consulted (#20316 contract — the cooldown
|
||
# exists precisely because compression recently failed).
|
||
if _compression_cooldown or _preflight_deferred or _codex_native_auto:
|
||
_engine_preflight = None
|
||
else:
|
||
_engine_preflight = getattr(
|
||
_compressor, "should_compress_preflight", None
|
||
)
|
||
# ── Engine-driven sub-threshold preflight maintenance (#20316) ──
|
||
# None of the threshold-path branches fired (not deferred, no
|
||
# failure cooldown, not codex-native, and should_compress() said
|
||
# the request is under pressure). Context engines that override
|
||
# ``should_compress_preflight()`` (e.g. LCM-style incremental
|
||
# leaf-chunk compaction) can still request deferred maintenance
|
||
# below the token threshold. The default
|
||
# ``ContextEngine.should_compress_preflight()`` returns False, so
|
||
# the built-in ``ContextCompressor`` path is byte-identical.
|
||
#
|
||
# Attempt-cap integration: the engine gets exactly ONE
|
||
# ``compress()`` pass per turn. It is mutually exclusive with the
|
||
# threshold multi-pass loop above (if/elif), so turn-start
|
||
# preflight passes stay bounded by the resolved
|
||
# ``compression.max_attempts`` cap (floor 1) in every case.
|
||
#
|
||
# No-op-blocking integration: a sub-threshold engine pass that
|
||
# no-ops says nothing about over-threshold compressibility, so it
|
||
# must neither set nor clear ``_preflight_compression_blocked``
|
||
# (#64382) — and being in the ``else`` arm it can never run after
|
||
# the threshold loop has proven a retry ineffective.
|
||
# (resolved above, gated on no skip-branch having fired)
|
||
_wants_engine_preflight = False
|
||
if callable(_engine_preflight):
|
||
try:
|
||
_wants_engine_preflight = bool(_engine_preflight(messages))
|
||
except Exception as _preflight_exc:
|
||
# A buggy engine must never break an otherwise-healthy
|
||
# turn: swallow at debug level and skip maintenance.
|
||
logger.debug(
|
||
"should_compress_preflight raised %s; skipping "
|
||
"engine-driven preflight maintenance",
|
||
_preflight_exc,
|
||
)
|
||
_wants_engine_preflight = False
|
||
if _wants_engine_preflight:
|
||
logger.info(
|
||
"Engine-driven preflight maintenance: %s requested "
|
||
"compress() at ~%s tokens (below %s threshold)",
|
||
getattr(_compressor, "name", type(_compressor).__name__),
|
||
f"{_preflight_tokens:,}",
|
||
f"{getattr(_compressor, 'threshold_tokens', 0):,}",
|
||
)
|
||
_engine_input = messages
|
||
messages, active_system_prompt = agent._compress_context(
|
||
messages, system_message, approx_tokens=_preflight_tokens,
|
||
task_id=effective_task_id,
|
||
)
|
||
# ``_compress_context`` returns the INPUT list object on every
|
||
# skip path (per-session lock held elsewhere, cooldown,
|
||
# anti-thrash breaker, codex-native routing) and an engine may
|
||
# legitimately no-op. Only re-baseline the flush history and
|
||
# re-anchor the user row after a REAL compaction — a skip must
|
||
# leave the turn's bookkeeping untouched.
|
||
if messages is not _engine_input:
|
||
_preflight_compressed = True
|
||
conversation_history = conversation_history_after_compression(
|
||
agent, messages
|
||
)
|
||
agent._empty_content_retries = 0
|
||
agent._thinking_prefill_retries = 0
|
||
agent._last_content_with_tools = None
|
||
agent._last_content_tools_all_housekeeping = False
|
||
agent._mute_post_response = False
|
||
elif not agent.compression_enabled:
|
||
# Uncompressed session guard (#89297): when compression is explicitly
|
||
# disabled, sessions can grow past the model's context window across
|
||
# hundreds of messages with nothing to shrink them. The warning itself
|
||
# fires from the conversation loop's pre-API site, which reuses the
|
||
# unconditionally computed request estimate at zero marginal cost and
|
||
# covers both turn-start and mid-turn growth (every provider request
|
||
# passes through it). Here we only RE-ARM the dedup once the session
|
||
# is back under the window, so the guard can warn again after the
|
||
# user compacts (/compress with force=True works with compression
|
||
# disabled) and the context later regrows past the limit.
|
||
_ctx_len = getattr(
|
||
getattr(agent, "context_compressor", None), "context_length", None
|
||
)
|
||
if isinstance(_ctx_len, int) and _ctx_len > 0:
|
||
_raw_chars = 0
|
||
for _m in messages:
|
||
if not isinstance(_m, dict):
|
||
continue
|
||
_c = _m.get("content")
|
||
if isinstance(_c, str):
|
||
_raw_chars += len(_c)
|
||
elif _c:
|
||
# Non-string, non-empty content (multimodal part lists,
|
||
# dict payloads) defeats a char count — force the real
|
||
# estimate by treating it as over-gate. None/"" (routine
|
||
# assistant tool-call rows) contribute nothing.
|
||
_raw_chars = _ctx_len + 1
|
||
break
|
||
# Cheap gate: a session whose raw text is under ~1/4 of the
|
||
# window (4 chars/token upper bound) cannot be over it — skip
|
||
# the estimator. Non-string (multimodal) content defeats a char
|
||
# count, so any such message forces the real estimate.
|
||
if _raw_chars <= _ctx_len:
|
||
_clear_warn = getattr(
|
||
agent, "_clear_context_overflow_warn", None
|
||
)
|
||
if callable(_clear_warn):
|
||
_clear_warn()
|
||
else:
|
||
# Route-aware (#96995/#97602 class): the warn site in the
|
||
# conversation loop now measures the checkpoint-pruned wire
|
||
# payload on native-Codex sessions, so the re-arm must use
|
||
# the same figure — otherwise a compacted session that fits
|
||
# on the wire never clears the dedup and future genuine
|
||
# overflow warnings stay suppressed.
|
||
_uncompressed_tokens = _preflight_request_tokens(
|
||
agent,
|
||
messages,
|
||
active_system_prompt or "",
|
||
)
|
||
if _uncompressed_tokens <= _ctx_len:
|
||
_clear_warn = getattr(
|
||
agent, "_clear_context_overflow_warn", None
|
||
)
|
||
if callable(_clear_warn):
|
||
_clear_warn()
|
||
|
||
if _preflight_compressed:
|
||
# Compression rebuilt the list (tail messages are fresh compaction
|
||
# copies), so the pre-compression index of this turn's user message
|
||
# is stale. Re-anchor both index trackers: the api_content stamp
|
||
# below, the loop's injection site, and the flush's persist-override
|
||
# row (#48677) must all target the surviving dict, not a stale
|
||
# position. Exact-content match first so a todo-snapshot user message
|
||
# appended after the tail can't steal the anchor.
|
||
current_turn_user_idx = reanchor_current_turn_user_idx(
|
||
messages, user_message
|
||
)
|
||
agent._persist_user_message_idx = current_turn_user_idx
|
||
|
||
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
|
||
plugin_user_context = ""
|
||
try:
|
||
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
|
||
_pre_results = _invoke_hook(
|
||
"pre_llm_call",
|
||
session_id=agent.session_id,
|
||
task_id=effective_task_id,
|
||
turn_id=turn_id,
|
||
user_message=original_user_message,
|
||
conversation_history=list(messages),
|
||
is_first_turn=(not bool(conversation_history)),
|
||
model=agent.model,
|
||
platform=getattr(agent, "platform", None) or "",
|
||
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
|
||
sender_id=getattr(agent, "_user_id", None) or "",
|
||
)
|
||
_ctx_parts: list[str] = []
|
||
# Spill oversized per-hook context to disk so a runaway plugin
|
||
# can't inflate every subsequent turn's prompt. Ported from
|
||
# openai/codex PR #21069 ("Spill large hook outputs from context").
|
||
try:
|
||
from tools.hook_output_spill import (
|
||
get_spill_config as _spill_cfg,
|
||
spill_if_oversized as _spill_if_oversized,
|
||
)
|
||
_spill_config_cached = _spill_cfg()
|
||
except Exception:
|
||
_spill_if_oversized = None # type: ignore[assignment]
|
||
_spill_config_cached = None
|
||
for r in _pre_results:
|
||
_piece: str = ""
|
||
if isinstance(r, dict) and r.get("context"):
|
||
_piece = str(r["context"])
|
||
elif isinstance(r, str) and r.strip():
|
||
_piece = r
|
||
else:
|
||
continue
|
||
if _spill_if_oversized is not None:
|
||
try:
|
||
_piece = _spill_if_oversized(
|
||
_piece,
|
||
session_id=agent.session_id,
|
||
source="plugin hook",
|
||
config=_spill_config_cached,
|
||
)
|
||
except Exception as _spill_exc:
|
||
logger.warning("hook context spill failed: %s", _spill_exc)
|
||
_ctx_parts.append(_piece)
|
||
if _ctx_parts:
|
||
plugin_user_context = "\n\n".join(_ctx_parts)
|
||
except Exception as exc:
|
||
logger.warning("pre_llm_call hook failed: %s", exc)
|
||
|
||
# Gateway must-deliver notes (auto-reset note, first-contact intro,
|
||
# voice-channel change) ride the same user-message injection channel as
|
||
# plugin context so the ephemeral system prompt can stay byte-stable.
|
||
# One-shot: staged by the gateway right before this turn, consumed here.
|
||
# Multimodal (list) content can't take the string sidecar — append a
|
||
# durable text part instead of dropping the fact.
|
||
_gateway_notes = consume_gateway_turn_context_notes(agent)
|
||
if _gateway_notes:
|
||
_gw_turn_content = (
|
||
messages[current_turn_user_idx].get("content")
|
||
if 0 <= current_turn_user_idx < len(messages)
|
||
and isinstance(messages[current_turn_user_idx], dict)
|
||
else None
|
||
)
|
||
if isinstance(_gw_turn_content, list):
|
||
append_notes_to_multimodal_content(_gw_turn_content, _gateway_notes)
|
||
else:
|
||
plugin_user_context = (
|
||
plugin_user_context + "\n\n" + _gateway_notes
|
||
if plugin_user_context
|
||
else _gateway_notes
|
||
)
|
||
|
||
# Per-turn file-mutation verifier state.
|
||
agent._turn_failed_file_mutations = {}
|
||
agent._turn_file_mutation_paths = set()
|
||
agent._verification_stop_nudges = 0
|
||
agent._pre_verify_nudges = 0
|
||
|
||
# Record the execution thread so interrupt()/clear_interrupt() can scope
|
||
# the tool-level interrupt signal to THIS agent's thread only.
|
||
agent._execution_thread_id = threading.current_thread().ident
|
||
|
||
# Clear stale per-thread interrupt state, preserving a pending interrupt.
|
||
ra()._set_interrupt(False, agent._execution_thread_id)
|
||
if agent._interrupt_requested:
|
||
ra()._set_interrupt(
|
||
True,
|
||
agent._execution_thread_id,
|
||
reason=getattr(agent, "_tool_interrupt_reason", None),
|
||
)
|
||
agent._interrupt_thread_signal_pending = False
|
||
else:
|
||
agent._interrupt_message = None
|
||
agent._tool_interrupt_reason = None
|
||
agent._interrupt_thread_signal_pending = False
|
||
|
||
# Notify memory providers of the new turn (BEFORE prefetch_all).
|
||
if agent._memory_manager:
|
||
try:
|
||
_turn_msg = original_user_message if isinstance(original_user_message, str) else ""
|
||
agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg)
|
||
except Exception:
|
||
pass
|
||
|
||
# External memory provider: prefetch once before the tool loop.
|
||
#
|
||
# Skip prefetch on trivial prompts (greetings, acknowledgements) to
|
||
# prevent memory-context injection on turns that carry no semantic signal.
|
||
ext_prefetch_cache = ""
|
||
if agent._memory_manager:
|
||
try:
|
||
_query = original_user_message if isinstance(original_user_message, str) else ""
|
||
if not is_trivial_prompt(_query):
|
||
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
|
||
except Exception:
|
||
pass
|
||
# Deterministic, model-independent recall indicator: when memory was
|
||
# actually injected this turn, tell the user — don't rely on the model
|
||
# to surface it. Rendered by Hermes (via _emit_status), so it always
|
||
# shows and can't be silently dropped by the model.
|
||
if ext_prefetch_cache:
|
||
try:
|
||
_recall_indicator = agent._memory_manager.describe_recall()
|
||
if _recall_indicator:
|
||
agent._emit_status(_recall_indicator)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── api_content sidecar: persist what you send ──
|
||
# The prefetch/plugin context above is injected into the API copy of this
|
||
# turn's user message, never into the stored content — so on the next
|
||
# turn the message would replay WITHOUT the injection, diverging the
|
||
# request prefix at this point and re-prefilling everything after it
|
||
# (the whole previous turn's assistant/tool chain). Stamp the exact
|
||
# API-bound bytes on the live dict, only when they differ from the clean
|
||
# content, so the crash persist below writes both in the same row and
|
||
# replay can reproduce the sent prefix byte-for-byte. Guarded by the
|
||
# same predicate the api_messages build uses, so the stamped bytes are
|
||
# exactly the bytes the loop sends. codex_app_server turns bypass the
|
||
# api_messages build entirely (the codex thread gets the plain user
|
||
# message), so stamping there would persist bytes that were never sent.
|
||
# MoA turns append per-call aggregated reference context to the same API
|
||
# copy AFTER this composition, so the stamped bytes would never match the
|
||
# wire either — skip the stamp rather than persist provably wrong "exact
|
||
# sent bytes" (MoA keeps its pre-sidecar cache behavior).
|
||
if (
|
||
not moa_active
|
||
and getattr(agent, "api_mode", None) != "codex_app_server"
|
||
and 0 <= current_turn_user_idx < len(messages)
|
||
and messages[current_turn_user_idx].get("role") == "user"
|
||
):
|
||
_turn_user_msg = messages[current_turn_user_idx]
|
||
_api_content = compose_user_api_content(
|
||
_turn_user_msg.get("content", ""), ext_prefetch_cache, plugin_user_context
|
||
)
|
||
if _api_content is not None and _api_content != _turn_user_msg.get("content"):
|
||
_turn_user_msg["api_content"] = _api_content
|
||
# In-place preflight compaction has ALREADY inserted this turn's
|
||
# user row (archive_and_compact runs before prefetch/pre_llm_call
|
||
# can compose the sidecar), and the crash persist below identity-
|
||
# skips every compacted dict (they are all in the rebound
|
||
# conversation_history) — so the stamp would never reach the DB.
|
||
# Backfill it onto the freshly-inserted row directly. Rotation
|
||
# mode needs nothing here: its compacted copies flush to the
|
||
# child session after this stamp.
|
||
if _preflight_compressed and bool(
|
||
getattr(agent, "_last_compaction_in_place", False)
|
||
):
|
||
_db = getattr(agent, "_session_db", None)
|
||
if _db is not None:
|
||
try:
|
||
_db.set_latest_user_api_content(
|
||
agent.session_id,
|
||
_turn_user_msg.get("content"),
|
||
_api_content,
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"in-place compaction api_content backfill failed "
|
||
"for session=%s",
|
||
agent.session_id or "none",
|
||
exc_info=True,
|
||
)
|
||
|
||
# Crash-resilience: persist the inbound user turn before the first LLM
|
||
# call. Runs after preflight compression (which rewrites history anyway)
|
||
# and after prefetch/pre_llm_call, so the user row is written once with
|
||
# its final api_content instead of being re-written mid-turn.
|
||
# Keep row creation and the marker-based append in the same per-agent
|
||
# critical section as CLI close persistence, and retry the row create if
|
||
# the pre-compression attempt above failed transiently.
|
||
def _ensure_and_persist() -> None:
|
||
agent._ensure_db_session()
|
||
agent._persist_session(messages, conversation_history)
|
||
|
||
try:
|
||
if persist_lock is None:
|
||
_ensure_and_persist()
|
||
else:
|
||
with persist_lock:
|
||
_ensure_and_persist()
|
||
except Exception:
|
||
logger.warning(
|
||
"Early turn-start session persistence failed for session=%s",
|
||
agent.session_id or "none",
|
||
exc_info=True,
|
||
)
|
||
finally:
|
||
# Keep an unmarked staged input available to a later close retry if the
|
||
# normal persistence attempt failed. Once the marker is present, the
|
||
# close path must no longer treat it as a pre-worker UI input.
|
||
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
|
||
agent._pending_cli_user_message = None
|
||
|
||
# Title the session from this user message, now — the row exists and the
|
||
# turn has not called the model yet. Titling is derived from the user's
|
||
# ask alone, so it runs concurrently with the turn instead of waiting for
|
||
# a final response; on a long tool-heavy first turn that is the difference
|
||
# between a title in ~1s and a title minutes later (or never, when the
|
||
# turn failed before producing one). Fire-and-forget on a daemon thread,
|
||
# a no-op once the session has a title, and shared by every surface
|
||
# because every surface enters the turn through this prologue.
|
||
_maybe_title_session_at_turn_start(agent, messages)
|
||
|
||
return TurnContext(
|
||
user_message=user_message,
|
||
original_user_message=original_user_message,
|
||
messages=messages,
|
||
conversation_history=conversation_history,
|
||
active_system_prompt=active_system_prompt,
|
||
effective_task_id=effective_task_id,
|
||
turn_id=turn_id,
|
||
current_turn_user_idx=current_turn_user_idx,
|
||
should_review_memory=should_review_memory,
|
||
plugin_user_context=plugin_user_context,
|
||
ext_prefetch_cache=ext_prefetch_cache,
|
||
preflight_compression_blocked=_preflight_compression_blocked,
|
||
)
|