"""Summary-hook dispatch and cancellation rollback for context compression.""" from __future__ import annotations import inspect from typing import TYPE_CHECKING, Any, Dict, List, Optional from agent.auxiliary_client import AuxiliaryExplicitCancellation if TYPE_CHECKING: from agent.context_compressor import _HandoffScan def _accepts_keyword_argument(callable_obj: Any, name: str) -> bool: """Return whether an inspectable callable accepts ``name`` as a keyword.""" try: parameters = inspect.signature(callable_obj).parameters except (TypeError, ValueError): return False if any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()): return True parameter = parameters.get(name) return parameter is not None and parameter.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ) class SummaryDispatchMixin: def _apply_summary_route(self, call_kwargs: dict, pinned: Optional[dict[str, Any]] = None) -> None: """Pin the summary route onto ``call_kwargs`` for both compression entry points. After a fallback, an omitted route is NOT "use the main model": ``call_llm`` re-resolves ``auxiliary.compression`` from config — the model that just failed — and a second fallback is refused, so every later attempt aborts (#123362). Name the main runtime explicitly. A stall-fallback ``pinned`` route replaces the whole route: merged over the main runtime, the main api_key/base_url would ride into the fallback entry's call (to its host, or instead of it). """ if pinned: # Clear first: a keyless pin (local server) resolves its own credential, never the main one. for key in ("provider", "model", "base_url", "api_key", "api_mode"): call_kwargs.pop(key, None) call_kwargs.update(pinned) return if self.summary_model: call_kwargs["model"] = self.summary_model return if not getattr(self, "_summary_model_fallen_back", False): return for key, value in ( ("provider", self.provider), ("model", self.model), ("base_url", self.base_url), ("api_key", self.api_key), ("api_mode", getattr(self, "api_mode", "")), ): if value: call_kwargs[key] = value def _summarize_window( self, messages: list[dict[str, Any]], turns_to_summarize: list[dict[str, Any]], scan: _HandoffScan, focus_topic: Optional[str], memory_context: str, bypass_cooldown: bool, ) -> Optional[str]: """Run the summary LLM; a cancellation rolls back the handoff scan's self-heal mutation first. A deterministic pin (repeated stall, #112420) skips the LLM: ``None`` lets Phase 3 insert the static fallback summary, or abort under ``abort_on_summary_failure`` exactly like a failed summary call.""" from agent.context_compressor import take_deterministic_summary_pin if take_deterministic_summary_pin(): # A detached stale attempt must not stamp error state or mutate the shared telemetry dict # the fallback owns; unwind as a cancellation before any write lands. from agent.conversation_compression import _raise_if_stale_attempt _raise_if_stale_attempt(self) # Surfaces through the fallback summary's reason line and the host's one-shot user warning. self._last_summary_error = ( "summary model stalled on every route; deterministic fallback summary inserted" ) telemetry = getattr(self, "_active_compression_telemetry", None) if isinstance(telemetry, dict): telemetry["failure_class"] = "stall_deterministic_fallback" return None # Focus-topic derivation scans user turns; only pay when a summary is generated. summary_kwargs: dict[str, Any] = { "focus_topic": focus_topic or self._derive_auto_focus_topic(messages), "memory_context": memory_context, } if _accepts_keyword_argument(self._generate_summary, "bypass_cooldown"): summary_kwargs["bypass_cooldown"] = bypass_cooldown try: return self._generate_summary(turns_to_summarize, **summary_kwargs) except AuxiliaryExplicitCancellation: # Cancellation is a true no-op: restore the scan's mutation before the exception escapes. # Guard by THIS attempt's ownership: a detached stale attempt (a fallback already claimed # and is working the compressor) must not revert summary state the newer attempt advanced. # The caller's own generation rides a ContextVar because shared compressor attributes can # only name the current owner, never the caller's attempt. Working-attempt comparison (not # the entry generation) so a no-op claim does not suppress the owning attempt's rollback. from agent.conversation_compression import _caller_attempt_is_current if _caller_attempt_is_current(self): self._previous_summary = scan.previous_summary_before self._summary_has_user_turn = scan.has_user_turn_before raise