diff --git a/ouroboros/context_budget.py b/ouroboros/context_budget.py index e7a18aa5..ea3d82d1 100644 --- a/ouroboros/context_budget.py +++ b/ouroboros/context_budget.py @@ -23,8 +23,24 @@ from __future__ import annotations # Main-loop emergency tool-history compaction trigger (~300K tokens at chars/4). # Remote routine compaction stays off by design; this is the overflow backstop. +# NECESSITY is judged on this budget in CALIBRATED real tokens (chars/4 × the +# main-loop measured density, neutral 1.0 cold): on a ~1.7×-dense Claude route +# the raw-char form silently meant ~500K real tokens, engaging only deep into +# the task and then thrashing (see the hysteresis constants below). EMERGENCY_COMPACTION_CHARS = 1_200_000 +# Emergency-compaction hysteresis (the necessity-vs-utility split). NECESSITY +# (compact at all?) is total calibrated pressure — the frozen frame (system +# blocks + tools + protected/kept rounds) counts toward the provider window. +# UTILITY (can a pass help?) is the COMPACTABLE region only: after a pass that +# could NOT get the context below the trigger (the frame alone exceeds it — +# the submarine wave3 shape: 35/35 rounds fired, each pass a light-model call +# plus a transcript rewrite that collapsed the prompt cache to the static +# floor), further passes are suppressed until the compactable transcript grows +# by this factor or this many rounds pass, whichever is first. +COMPACTION_HYSTERESIS_REGION_GROWTH = 1.2 +COMPACTION_HYSTERESIS_ROUNDS = 10 + # Background-consciousness assembled-context guards. P1: fail fast, never # silently truncate cognitive artifacts. BG_CONTEXT_WARN_CHARS = 600_000 # ~150K tokens: warn but proceed diff --git a/ouroboros/context_fit.py b/ouroboros/context_fit.py index 2fa1efc2..3b3024c7 100644 --- a/ouroboros/context_fit.py +++ b/ouroboros/context_fit.py @@ -197,6 +197,30 @@ def estimate_context_prompt_tokens( return max(0, int(total)) +def main_loop_token_density(drive_root: Any, model: str) -> float: + """MAIN-LOOP calibrated token density: neutral 1.0 cold, measured supersedes. + + The baseline `_route_calibration_ratio` starts from, exposed as its own SSOT so + the emergency-compaction necessity trigger and the fit projections share ONE + policy. DELIBERATELY NOT ``capability_evidence.resolve_token_density`` — that is + the review-pack COLD-CONSERVATIVE value, which on an empty observation store + (every fresh install and isolated benchmark server) would silently narrow the + main loop's horizon on a guess (the v6.80.0 → v6.81.0 oscillation; BIBLE P1). + Only a MEASURED density for this exact model identity may raise it above 1.0. + """ + baseline = 1.0 + try: + from ouroboros.capability_evidence import get_token_density + from ouroboros.provider_models import normalize_model_identity + + measured = get_token_density(drive_root, normalize_model_identity(str(model or ""))) + if measured > 0: + baseline = measured + except Exception: + log.debug("Measured token density unavailable", exc_info=True) + return baseline + + def _route_calibration_ratio( drive_root: pathlib.Path, route_fp: str, @@ -225,17 +249,7 @@ def _route_calibration_ratio( fresh evidence store; the first successful send records this model's density, after which the projection is measured rather than guessed. """ - baseline = 1.0 - try: - from ouroboros.capability_evidence import get_token_density - from ouroboros.provider_models import normalize_model_identity - - measured = get_token_density(drive_root, normalize_model_identity(str(model or ""))) - if measured > 0: - baseline = measured - except Exception: - log.debug("Measured token density unavailable for context fit", exc_info=True) - ratios = [float(baseline)] + ratios = [float(main_loop_token_density(drive_root, model))] try: events_path = pathlib.Path(drive_root) / "logs" / "events.jsonl" for event in iter_jsonl_objects( diff --git a/ouroboros/loop.py b/ouroboros/loop.py index a6518345..c829c161 100644 --- a/ouroboros/loop.py +++ b/ouroboros/loop.py @@ -21,7 +21,12 @@ from ouroboros.observability import new_call_id, persist_call from ouroboros.tool_policy import CAPABILITY_OMISSION_HEADER, format_capability_omissions, initial_tool_schemas, list_non_core_tools, swarm_router_turn from ouroboros.tools.registry import ToolRegistry from ouroboros.context import build_user_content, estimate_context_prompt_tokens -from ouroboros.context_budget import EMERGENCY_COMPACTION_CHARS, LOW_EMERGENCY_COMPACTION_CHARS +from ouroboros.context_budget import ( + COMPACTION_HYSTERESIS_REGION_GROWTH, + COMPACTION_HYSTERESIS_ROUNDS, + EMERGENCY_COMPACTION_CHARS, + LOW_EMERGENCY_COMPACTION_CHARS, +) from ouroboros.context_compaction import _tool_round_spans, compact_tool_history_llm from ouroboros.deadline_utils import parse_deadline_ts, utc_now from ouroboros.utils import estimate_tokens, truncate_review_artifact @@ -3144,27 +3149,87 @@ def _run_round_compaction( # compaction; max => 1.2M-char emergency-only (cache-friendly). No per-model # window table; the reactive provider-overflow detector (context.py) drops the # agent to low mode if a route's real window turns out smaller than assumed. + # + # NECESSITY vs UTILITY (the submarine thrash fix). NECESSITY — should we + # compact at all? — is TOTAL calibrated pressure: the frozen frame (system + # blocks, tools, protected/kept rounds) counts toward the provider window even + # though no pass can shrink it, and the char budget is compared in CALIBRATED + # real tokens (main_loop_token_density: neutral 1.0 cold, measured supersedes + # — never the review-pack cold-conservative value, which would demote fresh + # installs; the v6.80→v6.81 oscillation). UTILITY — can a pass help, and when + # should it refire? — is the COMPACTABLE region only (the transcript beyond + # the frozen frame): a pass that could NOT get below the trigger arms a + # hysteresis, one loud disclosure replaces the per-round light-model call + + # cache-destroying rewrite (wave3: 35/35 rounds fired because the LOW trigger + # sits below the irreducible low-mode frame), and the trigger re-arms only + # when the region grows ~20% or after N rounds. The reactive provider-overflow + # low-retry net (one-shot, loop exit path) is deliberately untouched. emergency_chars = LOW_EMERGENCY_COMPACTION_CHARS if ctx.active_context_mode == "low" else EMERGENCY_COMPACTION_CHARS - if _estimate_messages_chars(messages) > emergency_chars: + from ouroboros.context_fit import main_loop_token_density + + density = main_loop_token_density(ctx.drive_root, ctx.active_model) + threshold_real_tokens = emergency_chars / 4.0 # the token budget the char constant documents + pressure_real_tokens = (_estimate_messages_chars(messages) / 4.0) * density + if pressure_real_tokens > threshold_real_tokens: + usage_state = getattr(ctx.tools._ctx, "_accumulated_usage", None) + usage_state = usage_state if isinstance(usage_state, dict) else {} + spans = _tool_round_spans(messages) + region_chars = _estimate_messages_chars(messages[spans[0][0]:]) if spans else 0 + hysteresis = usage_state.get("_compaction_hysteresis") + if isinstance(hysteresis, dict): + armed_region = int(hysteresis.get("region_chars") or 0) + armed_round = int(hysteresis.get("round") or 0) + if ( + region_chars < armed_region * COMPACTION_HYSTERESIS_REGION_GROWTH + and (ctx.round_idx - armed_round) < COMPACTION_HYSTERESIS_ROUNDS + ): + return messages, None # armed: a pass cannot help yet (disclosed once, on arming) + usage_state.pop("_compaction_hysteresis", None) # keep_recent must stay BELOW the current span count or the compactor # no-ops (len(spans) <= keep_recent returns as-is): a transcript over # the emergency byte threshold with only ~50 huge rounds previously # never compacted at all. Halve the history (floor 6), but ALWAYS # clamp below the span count so even 2-6 huge rounds compact; with a # single round there is nothing older to summarize. - span_count = len(_tool_round_spans(messages)) + span_count = len(spans) emergency_keep_recent = min(50, max(6, span_count // 2), max(1, span_count - 1)) if _persist_compaction_checkpoint( messages, drive_root=ctx.drive_root, drive_logs=ctx.drive_logs, task_id=ctx.task_id, reason="emergency_context_size", keep_recent=emergency_keep_recent, round_idx=ctx.round_idx, event_queue=ctx.event_queue, ): - return compact_tool_history_llm( + messages, usage = compact_tool_history_llm( messages, keep_recent=emergency_keep_recent, drive_root=ctx.drive_root, task_id=ctx.task_id, ) + after_real_tokens = (_estimate_messages_chars(messages) / 4.0) * density + if after_real_tokens > threshold_real_tokens: + spans_after = _tool_round_spans(messages) + region_after = _estimate_messages_chars(messages[spans_after[0][0]:]) if spans_after else 0 + usage_state["_compaction_hysteresis"] = { + "round": ctx.round_idx, + "region_chars": region_after, + } + ctx.emit_progress( + "⚠️ Emergency compaction was futile: calibrated context " + f"≈{after_real_tokens / 1000:.0f}K real tokens still exceeds the " + f"≈{threshold_real_tokens / 1000:.0f}K trigger (the frozen frame cannot be " + "compacted). Further passes suppressed until the compactable transcript " + f"grows ≥{COMPACTION_HYSTERESIS_REGION_GROWTH:.1f}x or " + f"{COMPACTION_HYSTERESIS_ROUNDS} rounds pass." + ) + _emit_checkpoint_event(ctx.event_queue, ctx.task_id, ctx.drive_logs, { + "checkpoint_kind": "compaction_hysteresis_armed", + "round": ctx.round_idx, + "reason": "emergency_pass_futile", + "calibrated_real_tokens": int(after_real_tokens), + "threshold_real_tokens": int(threshold_real_tokens), + "compactable_region_chars": region_after, + "token_density": round(float(density), 3), + }) + return messages, usage ctx.emit_progress("⚠️ Emergency compaction skipped: forensic checkpoint could not be persisted.") return messages, None diff --git a/tests/test_loop_compaction.py b/tests/test_loop_compaction.py index da28d2b5..4b5e71e0 100644 --- a/tests/test_loop_compaction.py +++ b/tests/test_loop_compaction.py @@ -140,3 +140,116 @@ def test_context_compaction_observability_uses_current_task_drive(monkeypatch, t assert usage == {"prompt_tokens": 1} assert seen["drive_root"] == tmp_path assert seen["task_id"] == "task-42" + + +def test_emergency_compaction_necessity_uses_calibrated_density(monkeypatch, tmp_path): + """NECESSITY is total calibrated pressure: the char budget compared in REAL + tokens via the main-loop density baseline (neutral 1.0 cold, measured + supersedes) — on a measured ~1.7x-dense route the trigger fires before the + raw-char form would; on a cold store behavior is unchanged.""" + from ouroboros import context_fit, loop + + calls = [] + monkeypatch.setattr( + loop, "_persist_compaction_checkpoint", + lambda m, **k: calls.append(("checkpoint", k["reason"])) or True, + ) + monkeypatch.setattr( + loop, "compact_tool_history_llm", + lambda m, keep_recent, **k: (calls.append(("compact", keep_recent)) or (m, None)), + ) + # Raw chars sit BELOW the 1.2M max trigger; ~1.7x measured density puts the + # calibrated real-token pressure over it. + monkeypatch.setattr(loop, "_estimate_messages_chars", lambda _m: 800_000) + + def _ctx(density): + monkeypatch.setattr(context_fit, "main_loop_token_density", lambda _dr, _m: density) + return loop._CompactionRoundContext( + tools=SimpleNamespace(_ctx=SimpleNamespace(_pending_compaction=None, _accumulated_usage={})), + drive_root=tmp_path, drive_logs=tmp_path / "logs", task_id="task-cal", + round_idx=3, event_queue=None, active_use_local=False, + active_context_mode="max", checkpoint_injected=False, + emit_progress=lambda _msg: None, + ) + + messages = [] + for i in range(8): + messages.append({ + "role": "assistant", "content": f"r{i}", + "tool_calls": [{"id": f"c{i}", "function": {"name": "x", "arguments": "{}"}}], + }) + messages.append({"role": "tool", "tool_call_id": f"c{i}", "content": "ok"}) + + loop._run_round_compaction(messages, _ctx(1.7)) + assert ("checkpoint", "emergency_context_size") in calls + + calls.clear() + loop._run_round_compaction(messages, _ctx(1.0)) # cold baseline: unchanged behavior + assert calls == [] + + +def test_emergency_compaction_hysteresis_suppresses_futile_refire(monkeypatch, tmp_path): + """UTILITY/rearm: a pass that could not get below the trigger arms a + hysteresis — no per-round refire (no light-model call, no cache-destroying + rewrite) until the compactable region grows ~20% or N rounds pass. One loud + disclosure on arming.""" + from ouroboros import context_fit, loop + from ouroboros.context_budget import COMPACTION_HYSTERESIS_ROUNDS + + calls = [] + progress = [] + monkeypatch.setattr( + loop, "_persist_compaction_checkpoint", + lambda m, **k: calls.append("checkpoint") or True, + ) + # FUTILE pass: returns the transcript unchanged. + monkeypatch.setattr( + loop, "compact_tool_history_llm", + lambda m, keep_recent, **k: (calls.append("compact") or (m, None)), + ) + monkeypatch.setattr(context_fit, "main_loop_token_density", lambda _dr, _m: 1.0) + # Size scales with message count so the compactable region can grow. + monkeypatch.setattr(loop, "_estimate_messages_chars", lambda m: len(m) * 100_000) + + def _messages(rounds): + out = [] + for i in range(rounds): + out.append({ + "role": "assistant", "content": f"r{i}", + "tool_calls": [{"id": f"h{i}", "function": {"name": "x", "arguments": "{}"}}], + }) + out.append({"role": "tool", "tool_call_id": f"h{i}", "content": "ok"}) + return out + + state = {} + + def _ctx(round_idx): + return loop._CompactionRoundContext( + tools=SimpleNamespace(_ctx=SimpleNamespace(_pending_compaction=None, _accumulated_usage=state)), + drive_root=tmp_path, drive_logs=tmp_path / "logs", task_id="task-hyst", + round_idx=round_idx, event_queue=None, active_use_local=False, + active_context_mode="max", checkpoint_injected=False, + emit_progress=progress.append, + ) + + # 10 rounds x 2 msgs x 100K = 2M chars > 1.2M: fires, futile, arms. + loop._run_round_compaction(_messages(10), _ctx(3)) + assert calls == ["checkpoint", "compact"] + assert "_compaction_hysteresis" in state + assert any("futile" in p for p in progress) + + # Same region, next round: suppressed (no checkpoint, no light-model call). + calls.clear() + progress.clear() + loop._run_round_compaction(_messages(10), _ctx(4)) + assert calls == [] + assert progress == [] # disclosed once, on arming — not per round + + # Region grew >=20% (10 -> 13 rounds = 2.6M >= 2.4M): re-fires. + loop._run_round_compaction(_messages(13), _ctx(5)) + assert calls == ["checkpoint", "compact"] + + # Re-armed by the futile re-fire; N rounds later it re-fires on time alone. + calls.clear() + loop._run_round_compaction(_messages(13), _ctx(5 + COMPACTION_HYSTERESIS_ROUNDS)) + assert calls == ["checkpoint", "compact"]