File size: 15,535 Bytes
c61f7ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 | """Host-side ``AIAgent._compress_context`` wrapper.
Publishes the commit fence ``hard_interrupt()`` reads, runs the compressor on a snapshot under the progress
timeout, mirrors ``_DB_PERSISTED_MARKER`` stamps back onto the live lists and rebinds the session context.
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
"""
import contextlib
import logging
import copy
import threading
from agent.session_activity import ActivityProvenance
# Same logger name as the origin module so log records / caplog filters are unchanged.
logger = logging.getLogger("run_agent")
def _timeout_fallback_prompt(agent, system_message: str) -> str:
"""Cached prompt, else a fresh build, else the raw ``system_message`` (never raises).
Resolved lazily by the timeout wrapper: an eager rebuild would raise before compress_context runs when
``_cached_system_prompt`` is unset and the builder fails."""
if cached := getattr(agent, "_cached_system_prompt", None):
return cached
try:
return agent._build_system_prompt(system_message)
except Exception:
logger.debug("compress_context timeout fallback prompt rebuild failed; using raw system_message", exc_info=True)
return system_message or ""
def _report_compression_timeout(
agent, *, idle: float, waited: float, since_progress: float, total_ceiling: float, total_exhausted: bool,
progress_observed: bool,
) -> None:
"""Host-side timeout bookkeeping: log, activity stamp, cooldown ladder, user warning."""
from agent.conversation_compression import mark_context_compression_timed_out
mark_context_compression_timed_out(agent)
if total_exhausted:
logger.warning(
"Context compression reached its total ceiling after %.1fs (progress observed=%s); continuing without compression",
waited, progress_observed,
)
else:
logger.warning(
"Context compression made no progress for %.1fs (total wait %.1fs, ceiling %.1fs); continuing without compression",
since_progress, waited, total_ceiling,
)
touch = getattr(agent, "_touch_activity", None)
if callable(touch):
try:
touch("context compression timed out", provenance=ActivityProvenance.AGENT_COMPRESSION_TIMEOUT)
except Exception:
logger.debug("compress_context timeout activity touch failed", exc_info=True)
# Same timeout cooldown ladder as summary-LLM timeouts: avoid re-burning the full idle budget every turn.
record = getattr(getattr(agent, "context_compressor", None), "record_timeout_failure", None)
if callable(record):
try:
if total_exhausted:
record("host compress_context total ceiling exhausted", failure_kind="ceiling_exhausted")
else:
record("host compress_context timeout (no summary progress)", failure_kind="stalled")
except Exception:
logger.debug("failed to record compress_context timeout cooldown", exc_info=True)
emit = getattr(agent, "_emit_warning", None)
if not callable(emit):
return
if total_exhausted:
progress = " after summary output was observed" if progress_observed else ""
emit(
"⚠ Context compression reached its total ceiling "
f"after {waited:.1f}s{progress}. No messages were "
"dropped — continuing without compression. Run /compress to retry or /new for a clean session."
)
else:
emit(
f"⚠ Context compression timed out after {idle:.1f}s with no output from the summary "
"model. No messages were dropped — continuing without compression. Run /compress to retry, /new "
"for a clean session, or check auxiliary.compression."
)
def _warn_commit_overrun(agent, waited: float, ceiling: float) -> None:
"""Commit-phase ceiling breach: the SessionDB mutation must complete, so only surface it."""
emit = getattr(agent, "_emit_warning", None)
if callable(emit):
emit(
f"⚠ Context compression commit is taking unusually long ({waited:.0f}s, ceiling {ceiling:.0f}s). "
"Waiting for it to finish safely — if this persists, check SessionDB health (disk / lock contention)."
)
def _sync_persisted_markers(target_messages, source_messages) -> None:
"""Mirror ``_DB_PERSISTED_MARKER`` stamps from the worker's snapshot onto a live list.
Matched by scoped identity; timestamp-less repeated content is ambiguous, so every scoped match is
stamped. Imported UNCONDITIONALLY: a silent fallback literal would split the stamping key from the flush's
and resurrect the duplicate-row bug."""
from agent.context_compressor import _DB_PERSISTED_MARKER
from agent.conversation_compression import _stamp_scoped_twins
if not isinstance(target_messages, list) or not isinstance(source_messages, list):
return
for source_message in source_messages:
if isinstance(source_message, dict) and source_message.get(_DB_PERSISTED_MARKER):
_stamp_scoped_twins(target_messages, source_message)
def _run_under_progress_timeout(
agent, run, messages, system_message, *, active_fence, fence_registration_lock, idle_timeout, total_ceiling
):
"""Run ``run(fence, target_messages=snapshot)`` on the pool under the progress-aware timeout.
The pooled worker must NEVER share the caller's live transcript — a late engine after a host timeout could
rewrite it. It deep-snapshots on the worker and publishes only via an ADMITTED commit; a no-op/abort
returns the snapshot unchanged, so the ORIGINAL list is handed back to keep identity semantics."""
from agent.conversation_compression import CompressionCommitFence, run_compress_context_with_progress_timeout
def _snapshot_worker(fence=None):
# #76354 review F3: the pooled worker must NEVER share the caller's live transcript. Plugin/legacy
# context engines are allowed to mutate their input list in place; after a host timeout the worker
# stays alive, so a shared list would let a late engine rewrite the live conversation (roles,
# ordering, persisted content) behind the caller's back. Deep-snapshot here, on the worker thread,
# so the caller's list object is never touched by pooled code. Results are published to
# caller-visible state only via the returned value of an ADMITTED commit (the host discards results
# on timeout/cancel); durable SessionDB mutation is already gated behind the commit fence inside
# compress_context.
snapshot = copy.deepcopy(messages)
result_msgs, result_prompt = run(fence, target_messages=snapshot)
return (messages if result_msgs is snapshot else result_msgs), result_prompt
timeout_cause = {"total_exhausted": False, "progress_observed": False}
def _on_timeout_cause(total_exhausted, progress_observed):
timeout_cause.update(total_exhausted=total_exhausted, progress_observed=progress_observed)
def _on_timeout(idle, waited, since_progress):
_report_compression_timeout(
agent, idle=idle, waited=waited, since_progress=since_progress, total_ceiling=total_ceiling, **timeout_cause
)
def _publish_new_fence():
# The stall-fallback retry needs a fence the aborted attempt cannot veto; publish
# it on the slot hard_interrupt() reads. The caller's finally restores its fence.
retry_fence = CompressionCommitFence()
with fence_registration_lock:
agent._active_compression_commit_fence = retry_fence
return retry_fence
return run_compress_context_with_progress_timeout(
worker=_snapshot_worker, messages=messages,
system_prompt_fallback=lambda: _timeout_fallback_prompt(agent, system_message),
idle_timeout_seconds=idle_timeout, total_ceiling_seconds=total_ceiling, on_timeout=_on_timeout,
on_timeout_cause=_on_timeout_cause,
on_commit_overrun=lambda waited, ceiling: _warn_commit_overrun(agent, waited, ceiling), fence=active_fence,
telemetry_agent=agent, new_fence=_publish_new_fence,
)
def _mirror_result_onto_live_lists(agent, result, messages, *, direct_path: bool) -> None:
"""Mirror persisted-marker stamps from the result list onto the live list(s)."""
if not (isinstance(result, tuple) and result and isinstance(result[0], list)):
return
result_messages = result[0]
# Direct-path callers bypass the snapshot worker but still need the post-publish mirror.
if direct_path or result_messages is not messages:
_sync_persisted_markers(messages, result_messages)
session_messages = getattr(agent, "_session_messages", None)
if isinstance(session_messages, list) and session_messages is not messages:
# Durable-parent adoption can leave `_session_messages` on the pre-adoption list.
_sync_persisted_markers(session_messages, result_messages)
def _rebind_caller_session_context(agent) -> None:
"""Propagate a rotated session id to the CALLER's thread/ContextVar (idempotent otherwise).
The worker thread rotated hermes_logging's thread-local id; post-compression tools must resolve
HERMES_SESSION_ID to the child id."""
with contextlib.suppress(Exception):
from hermes_logging import set_session_context
set_session_context(agent.session_id)
try:
from gateway.session_context import set_current_session_id
if agent.session_id:
set_current_session_id(agent.session_id)
except Exception:
logger.debug("post-compression session ContextVar rebind failed", exc_info=True)
class CompressionFacadeMixin:
"""``_compress_context`` (see module docstring)."""
def _compress_context(
self, messages: list, system_message: str, *, approx_tokens: int = None, task_id: str = "default",
focus_topic: str = None, force: bool = False, bypass_cooldown: bool = False,
defer_context_engine_notification: bool = False, commit_fence=None,
) -> tuple:
"""Forwarder — see ``agent.conversation_compression.compress_context``.
``force=True`` (manual /compress) bypasses the summary-failure cooldown; ``bypass_cooldown=True``
(provider-proven overflow recovery) runs one real attempt while the cooldown stays armed.
``force=True`` is passed by the manual ``/compress`` slash command so users can bypass the
summary-failure cooldown after an auto-compress abort. Auto-compress callers use the default
``force=False``. See #100661.
"""
# Per-attempt timeout signal for turn-start preflight and in-loop consumers: a stalled
# compression must not be mistaken for a structural no-op. Thread-local + per-agent lock.
# A stalled compression must not be mistaken for a structural no-op and followed by the oversized
# provider request it was meant to prevent. The typed helper upgrades the simple attribute to
# thread-local state guarded by a per-agent lock so overlapping automatic/manual entrypoints cannot
# clobber each other's outcome (#98741).
from agent.conversation_compression import (
CompressionCommitFence, compress_context, reset_context_compression_timeout_outcome,
resolve_context_compression_timeouts,
)
reset_context_compression_timeout_outcome(self)
from agent.portal_tags import (
get_affinity_scope, get_conversation_context, reset_affinity_scope, reset_conversation_context,
set_affinity_scope, set_conversation_context,
)
from agent.prompt_cache_scope import declared_conversation_scope_safe
# Out-of-turn compaction (/compact, gateway /compress, partial head compression) runs outside
# run_conversation's ambient scope; publish the root as a fallback so the summarizer's call carries
# the conversation tag. No-op for in-turn callers. Same for the ROUTING scope when declared.
token = None
if get_conversation_context() is None:
root = self._conversation_root_id()
if root:
token = set_conversation_context(root)
# Initialized alongside `token`: the turn-lease timeout/interrupt early returns leave the try block
# before set_affinity_scope() runs, and the finally reads this name unconditionally
# (UnboundLocalError otherwise — the 4 red cross-process lease tests on PR #97158).
affinity_token = None
if get_affinity_scope() is None:
declared = declared_conversation_scope_safe(self)
if declared:
affinity_token = set_affinity_scope(declared)
# Every compression has a fence; hard_interrupt() uses this exact instance to serialize cancel
# admission against begin_commit(). Publication is serialized so overlapping automatic/manual
# entrypoints cannot replace the fence of the attempt currently committing.
active_fence = commit_fence or CompressionCommitFence()
fence_registration_lock = vars(self).setdefault("_compression_commit_fence_lock", threading.RLock())
with fence_registration_lock:
missing_fence = object()
previous_fence = vars(self).get("_active_compression_commit_fence", missing_fence)
self._active_compression_commit_fence = active_fence
try:
def _run(fence=None, target_messages=None):
return compress_context(
self, target_messages if target_messages is not None else messages, system_message,
approx_tokens=approx_tokens, task_id=task_id, focus_topic=focus_topic, force=force,
bypass_cooldown=bypass_cooldown,
defer_context_engine_notification=(defer_context_engine_notification), commit_fence=fence,
)
# Callers that already own a progress-aware wait (gateway session
# hygiene) pass commit_fence and must not be double-wrapped.
direct_path = commit_fence is not None
if not direct_path:
idle_timeout, total_ceiling = resolve_context_compression_timeouts()
direct_path = idle_timeout <= 0
if direct_path:
result = _run(active_fence)
else:
result = _run_under_progress_timeout(
self, _run, messages, system_message,
active_fence=active_fence, fence_registration_lock=fence_registration_lock,
idle_timeout=idle_timeout, total_ceiling=total_ceiling,
)
_mirror_result_onto_live_lists(self, result, messages, direct_path=direct_path)
_rebind_caller_session_context(self)
return result
finally:
with fence_registration_lock:
if previous_fence is missing_fence:
vars(self).pop("_active_compression_commit_fence", None)
else:
self._active_compression_commit_fence = previous_fence
# Restore whatever the caller had, so a compaction never leaks its tag into the surrounding scope.
if token is not None:
reset_conversation_context(token)
if affinity_token is not None:
reset_affinity_scope(affinity_token)
|