Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""Stall-interrupted preflight compression must persist a durable backoff.
|
||||
|
||||
#96775: an explicit /stop after the summary stream has already crossed the
|
||||
no-progress stall window restores the original transcript but must record a
|
||||
stall-specific cooldown so the next automatic turn does not re-enter the
|
||||
same strategy. An ordinary early /stop stays cooldown-neutral.
|
||||
|
||||
Native Codex app-server compaction is a sibling path (#75364) and is not
|
||||
covered here. Stall classification reads the commit fence's progress clock,
|
||||
not Chat Completions vs Responses frame shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.auxiliary_client import AuxiliaryExplicitCancellation
|
||||
from agent.conversation_compression import (
|
||||
STALL_INTERRUPTED_FAILURE_CLASS,
|
||||
CompressionCommitFence,
|
||||
compress_context,
|
||||
compression_attempt_stalled,
|
||||
)
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
def _build_agent(tmp_path: Path, session_id: str = "STALL_INTERRUPT_96775"):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session(session_id, source="cli")
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
session_db=db,
|
||||
session_id=session_id,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent._compression_feasibility_checked = True
|
||||
agent.compression_in_place = True
|
||||
agent._cached_system_prompt = "sys"
|
||||
agent.context_compressor.threshold_tokens = 1_000
|
||||
return db, agent
|
||||
|
||||
|
||||
def _messages():
|
||||
return [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
||||
|
||||
|
||||
def _age_fence(fence: CompressionCommitFence, idle_seconds: float) -> None:
|
||||
fence._last_progress = time.monotonic() - float(idle_seconds)
|
||||
|
||||
|
||||
class TestStallClassificationIsFenceIdle:
|
||||
def test_fresh_fence_is_not_stalled(self):
|
||||
fence = CompressionCommitFence()
|
||||
assert compression_attempt_stalled(
|
||||
commit_fence=fence,
|
||||
started_at=time.monotonic(),
|
||||
idle_timeout_seconds=1.0,
|
||||
) is False
|
||||
|
||||
def test_idle_fence_is_stalled(self):
|
||||
fence = CompressionCommitFence()
|
||||
_age_fence(fence, 2.0)
|
||||
assert compression_attempt_stalled(
|
||||
commit_fence=fence,
|
||||
started_at=time.monotonic(),
|
||||
idle_timeout_seconds=1.0,
|
||||
) is True
|
||||
|
||||
def test_recent_progress_is_not_stalled(self):
|
||||
fence = CompressionCommitFence()
|
||||
_age_fence(fence, 2.0)
|
||||
fence.touch_progress()
|
||||
assert compression_attempt_stalled(
|
||||
commit_fence=fence,
|
||||
started_at=time.monotonic() - 5.0,
|
||||
idle_timeout_seconds=1.0,
|
||||
) is False
|
||||
|
||||
def test_chat_and_responses_share_the_fence_clock(self):
|
||||
"""Transport-agnostic: both APIs tick the same fence or they don't."""
|
||||
chat_fence = CompressionCommitFence()
|
||||
responses_fence = CompressionCommitFence()
|
||||
_age_fence(chat_fence, 2.0)
|
||||
responses_fence.touch_progress()
|
||||
assert compression_attempt_stalled(
|
||||
commit_fence=chat_fence,
|
||||
started_at=time.monotonic(),
|
||||
idle_timeout_seconds=1.0,
|
||||
) is True
|
||||
assert compression_attempt_stalled(
|
||||
commit_fence=responses_fence,
|
||||
started_at=time.monotonic(),
|
||||
idle_timeout_seconds=1.0,
|
||||
) is False
|
||||
|
||||
|
||||
class TestEarlyStopStaysNeutral:
|
||||
def test_explicit_interrupt_before_stall_does_not_arm_cooldown(
|
||||
self, tmp_path: Path
|
||||
):
|
||||
db, agent = _build_agent(tmp_path, "EARLY_STOP_96775")
|
||||
original = _messages()
|
||||
live = copy.deepcopy(original)
|
||||
fence = CompressionCommitFence()
|
||||
|
||||
def _early_stop(messages, **_kwargs):
|
||||
messages[0]["content"] = "must be rolled back"
|
||||
raise AuxiliaryExplicitCancellation()
|
||||
|
||||
agent.context_compressor.compress = _early_stop
|
||||
|
||||
compressed, _prompt = compress_context(
|
||||
agent,
|
||||
live,
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
commit_fence=fence,
|
||||
)
|
||||
|
||||
assert compressed == original
|
||||
assert live == original
|
||||
assert db.get_compression_lock_holder("EARLY_STOP_96775") is None
|
||||
assert db.get_compression_failure_cooldown("EARLY_STOP_96775") is None
|
||||
assert agent.context_compressor.should_compress(50_000) is True
|
||||
db.append_message("EARLY_STOP_96775", "assistant", "still writable")
|
||||
|
||||
|
||||
class TestStallInterruptedBackoff:
|
||||
def test_aux_explicit_cancel_after_stall_persists_backoff(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"agent.conversation_compression.resolve_context_compression_timeouts",
|
||||
lambda compression_cfg=None: (1.0, 10.0),
|
||||
)
|
||||
db, agent = _build_agent(tmp_path, "STALL_AUX_CANCEL")
|
||||
original = _messages()
|
||||
live = copy.deepcopy(original)
|
||||
fence = CompressionCommitFence()
|
||||
compress_calls = {"n": 0}
|
||||
|
||||
def _stalled_then_stop(messages, **_kwargs):
|
||||
compress_calls["n"] += 1
|
||||
_age_fence(fence, 2.0)
|
||||
messages[0]["content"] = "must be rolled back"
|
||||
raise AuxiliaryExplicitCancellation()
|
||||
|
||||
agent.context_compressor.compress = _stalled_then_stop
|
||||
|
||||
compressed, _prompt = compress_context(
|
||||
agent,
|
||||
live,
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
commit_fence=fence,
|
||||
)
|
||||
|
||||
assert compressed == original
|
||||
assert live == original
|
||||
assert db.get_compression_lock_holder("STALL_AUX_CANCEL") is None
|
||||
state = db.get_compression_failure_cooldown("STALL_AUX_CANCEL")
|
||||
assert state is not None
|
||||
assert STALL_INTERRUPTED_FAILURE_CLASS in str(state["error"])
|
||||
assert "msgs=20" in str(state["error"])
|
||||
assert agent.context_compressor.should_compress(50_000) is False
|
||||
|
||||
compress_calls["n"] = 0
|
||||
again, _ = compress_context(
|
||||
agent,
|
||||
copy.deepcopy(original),
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
commit_fence=CompressionCommitFence(),
|
||||
)
|
||||
assert again == original
|
||||
assert compress_calls["n"] == 0
|
||||
|
||||
db.append_message("STALL_AUX_CANCEL", "assistant", "still writable")
|
||||
|
||||
def test_commit_fence_cancel_after_stall_persists_backoff(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"agent.conversation_compression.resolve_context_compression_timeouts",
|
||||
lambda compression_cfg=None: (1.0, 10.0),
|
||||
)
|
||||
db, agent = _build_agent(tmp_path, "STALL_FENCE_CANCEL")
|
||||
original = _messages()
|
||||
live = copy.deepcopy(original)
|
||||
fence = CompressionCommitFence()
|
||||
|
||||
def _summary_then_cancel(messages, **_kwargs):
|
||||
_age_fence(fence, 2.0)
|
||||
fence.cancel_before_commit()
|
||||
return [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
||||
{"role": "assistant", "content": "tail"},
|
||||
]
|
||||
|
||||
agent.context_compressor.compress = _summary_then_cancel
|
||||
|
||||
compressed, _prompt = compress_context(
|
||||
agent,
|
||||
live,
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
commit_fence=fence,
|
||||
)
|
||||
|
||||
assert compressed == original
|
||||
assert live == original
|
||||
state = db.get_compression_failure_cooldown("STALL_FENCE_CANCEL")
|
||||
assert state is not None
|
||||
assert STALL_INTERRUPTED_FAILURE_CLASS in str(state["error"])
|
||||
assert agent.context_compressor.should_compress(50_000) is False
|
||||
db.append_message("STALL_FENCE_CANCEL", "user", "next turn")
|
||||
|
||||
def test_commit_fence_cancel_with_fresh_progress_stays_neutral(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"agent.conversation_compression.resolve_context_compression_timeouts",
|
||||
lambda compression_cfg=None: (1.0, 10.0),
|
||||
)
|
||||
db, agent = _build_agent(tmp_path, "FRESH_FENCE_CANCEL")
|
||||
original = _messages()
|
||||
live = copy.deepcopy(original)
|
||||
fence = CompressionCommitFence()
|
||||
|
||||
def _healthy_then_cancel(messages, **_kwargs):
|
||||
fence.touch_progress()
|
||||
fence.cancel_before_commit()
|
||||
return [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
||||
{"role": "assistant", "content": "tail"},
|
||||
]
|
||||
|
||||
agent.context_compressor.compress = _healthy_then_cancel
|
||||
|
||||
compressed, _prompt = compress_context(
|
||||
agent,
|
||||
live,
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
commit_fence=fence,
|
||||
)
|
||||
|
||||
assert compressed == original
|
||||
assert db.get_compression_failure_cooldown("FRESH_FENCE_CANCEL") is None
|
||||
assert agent.context_compressor.should_compress(50_000) is True
|
||||
|
||||
def test_manual_force_bypasses_stall_backoff(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"agent.conversation_compression.resolve_context_compression_timeouts",
|
||||
lambda compression_cfg=None: (1.0, 10.0),
|
||||
)
|
||||
db, agent = _build_agent(tmp_path, "FORCE_BYPASS_STALL")
|
||||
original = _messages()
|
||||
fence = CompressionCommitFence()
|
||||
|
||||
def _stalled_then_stop(messages, **_kwargs):
|
||||
_age_fence(fence, 2.0)
|
||||
raise AuxiliaryExplicitCancellation()
|
||||
|
||||
agent.context_compressor.compress = _stalled_then_stop
|
||||
compress_context(
|
||||
agent,
|
||||
copy.deepcopy(original),
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
commit_fence=fence,
|
||||
)
|
||||
assert agent.context_compressor.should_compress(50_000) is False
|
||||
|
||||
forced_calls = {"n": 0}
|
||||
|
||||
def _forced(messages, **kwargs):
|
||||
forced_calls["n"] += 1
|
||||
assert kwargs.get("force") is True
|
||||
return messages
|
||||
|
||||
agent.context_compressor.compress = _forced
|
||||
compress_context(
|
||||
agent,
|
||||
copy.deepcopy(original),
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
force=True,
|
||||
commit_fence=CompressionCommitFence(),
|
||||
)
|
||||
assert forced_calls["n"] == 1
|
||||
|
||||
def test_stall_backoff_merges_with_longer_active_deadline(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"agent.conversation_compression.resolve_context_compression_timeouts",
|
||||
lambda compression_cfg=None: (1.0, 10.0),
|
||||
)
|
||||
db, agent = _build_agent(tmp_path, "MERGE_MAX_STALL")
|
||||
agent.context_compressor._record_compression_failure_cooldown(
|
||||
900.0, "prior timeout"
|
||||
)
|
||||
before = db.get_compression_failure_cooldown("MERGE_MAX_STALL")
|
||||
assert before is not None
|
||||
prior_until = float(before["cooldown_until"])
|
||||
|
||||
fence = CompressionCommitFence()
|
||||
|
||||
def _stalled_then_stop(messages, **_kwargs):
|
||||
_age_fence(fence, 2.0)
|
||||
raise AuxiliaryExplicitCancellation()
|
||||
|
||||
agent.context_compressor.compress = _stalled_then_stop
|
||||
# force=True so the pre-existing cooldown does not skip the attempt
|
||||
# before the stall-interrupt restore/merge path can run.
|
||||
compress_context(
|
||||
agent,
|
||||
_messages(),
|
||||
"sys",
|
||||
approx_tokens=50_000,
|
||||
force=True,
|
||||
commit_fence=fence,
|
||||
)
|
||||
|
||||
after = db.get_compression_failure_cooldown("MERGE_MAX_STALL")
|
||||
assert after is not None
|
||||
assert float(after["cooldown_until"]) >= prior_until - 1.0
|
||||
assert STALL_INTERRUPTED_FAILURE_CLASS in str(after["error"])
|
||||
|
||||
|
||||
def test_session_db_cooldown_write_does_not_shorten_longer_deadline(
|
||||
tmp_path: Path,
|
||||
):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session("merge-max", source="cli")
|
||||
later = time.time() + 900.0
|
||||
sooner = time.time() + 60.0
|
||||
db.record_compression_failure_cooldown("merge-max", later, "timeout")
|
||||
db.record_compression_failure_cooldown(
|
||||
"merge-max", sooner, STALL_INTERRUPTED_FAILURE_CLASS
|
||||
)
|
||||
row = db.get_compression_failure_cooldown("merge-max")
|
||||
assert row is not None
|
||||
assert float(row["cooldown_until"]) >= later - 1.0
|
||||
assert row["error"] == STALL_INTERRUPTED_FAILURE_CLASS
|
||||
Reference in New Issue
Block a user