diff --git a/gateway/run.py b/gateway/run.py index 6a30d443b78..e67f9e6e00f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9682,6 +9682,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew self._set_session_reasoning_override(session_key, None) if hasattr(self, "_pending_model_notes"): self._pending_model_notes.pop(session_key, None) + # Evict the cached agent so the fresh session does not inherit the + # previous conversation's context_compressor._previous_summary — + # the cache is keyed on the stable session_key, so an auto-reset + # otherwise reuses the old agent and leaks prior history into new + # compaction summaries. Mirrors /reset and the compression-exhausted + # path (#9893). Covers daily/idle/suspended auto-reset. + self._evict_cached_agent(session_key) session_entry.was_auto_reset = False # Emit session:start for new or auto-reset sessions @@ -9786,11 +9793,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # (single source of truth); only the reset reason needs clearing here. session_entry.auto_reset_reason = None - # Evict cached agent to prevent stale context_compressor._previous_summary - # from leaking into the new session after auto-reset (daily/idle/suspended). - # Follow-up to #9893 which only handled compression_exhausted case. - self._evict_cached_agent(session_key) - # Auto-load skill(s) for topic/channel bindings (Telegram DM Topics, # Discord channel_skill_bindings). Supports a single name or ordered list. # Only inject on NEW sessions — ongoing conversations already have the diff --git a/scripts/release.py b/scripts/release.py index 45b01cf4f22..2505af4f294 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -173,6 +173,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "mailtowbd@gmail.com": "marco0158", "157793278+jacobmansonlkevincc@users.noreply.github.com": "lkevincc0", "121278003+Cossackx@users.noreply.github.com": "Cossackx", # PR #52528 salvage (Windows hermes-shim resolution + prefer --update on recovery; #52378) "97326386+Icather@users.noreply.github.com": "Icather", # PR #45554 salvage (self-lock guard breaks Windows update-recovery infinite loop; #52378 / #45542) diff --git a/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py b/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py new file mode 100644 index 00000000000..05e5dea2cad --- /dev/null +++ b/tests/gateway/test_10710_auto_reset_evicts_cached_agent.py @@ -0,0 +1,92 @@ +"""Regression test for #10710 — stale context summary leak after auto-reset. + +The gateway agent cache is keyed on the stable chat ``session_key``, which does +NOT change when a session is auto-reset (daily schedule / idle timeout / +suspended). So unless the cached agent is explicitly evicted on auto-reset, the +NEXT message reuses the old ``AIAgent`` instance — carrying its +``context_compressor._previous_summary`` — and prior-conversation content leaks +into the new session's compaction summaries. + +Manual ``/reset`` and the compression-exhausted path (#9893) already evict the +cached agent. This pins the matching eviction onto the auto-reset cleanup block +in ``_handle_message_with_agent``. + +These are AST invariants — load-bearing pins that fail if the eviction is +removed from the cleanup block (mirrors +test_48031_model_switch_after_auto_reset.py's approach). +""" +from __future__ import annotations + +import ast +import inspect + +from gateway import run as gateway_run + + +def _calls(node: ast.AST) -> set[str]: + """Method-call attribute names invoked anywhere under ``node``.""" + return { + n.func.attr + for n in ast.walk(node) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + } + + +def _assigns_false(node: ast.AST, attr: str) -> bool: + """True if ``node`` contains an assignment ``. = False``.""" + for sub in ast.walk(node): + if isinstance(sub, ast.Assign): + for tgt in sub.targets: + if ( + isinstance(tgt, ast.Attribute) + and tgt.attr == attr + and isinstance(sub.value, ast.Constant) + and sub.value.value is False + ): + return True + return False + + +def test_auto_reset_cleanup_evicts_cached_agent(): + """The auto-reset cleanup block in gateway/run.py must call + ``_evict_cached_agent`` so the fresh session does not reuse the previous + conversation's cached agent (and its leaked + ``context_compressor._previous_summary``) — the cache is keyed on the + stable ``session_key`` (#10710).""" + tree = ast.parse(inspect.getsource(gateway_run)) + + # Fingerprint the cleanup branch: the `if :` block that + # drops transient session state (calls the reasoning-override setter AND + # consumes the flag by setting was_auto_reset = False). The eviction must + # live in that same block. + found = False + for node in ast.walk(tree): + if not isinstance(node, ast.If): + continue + calls = _calls(node) + if ( + "_set_session_reasoning_override" in calls + and _assigns_false(node, "was_auto_reset") + ): + assert "_evict_cached_agent" in calls, ( + "gateway/run.py auto-reset cleanup block must call " + "`_evict_cached_agent(session_key)` so the auto-reset session " + "does not reuse the previous cached agent and leak its " + "context_compressor._previous_summary into new compaction " + "summaries (#10710)." + ) + found = True + break + assert found, ( + "could not locate the auto-reset transient-state cleanup block in " + "gateway/run.py (fingerprint: _set_session_reasoning_override + " + "was_auto_reset = False)." + ) + + +def test_evict_cached_agent_method_exists(): + """The eviction helper the cleanup relies on must exist on the runner.""" + assert hasattr(gateway_run.GatewayRunner, "_evict_cached_agent"), ( + "GatewayRunner._evict_cached_agent is the helper the auto-reset " + "cleanup depends on (#10710)." + )