mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-18 07:13:17 +00:00
fix(context): emergency compaction — necessity vs utility split with hysteresis
The submarine thrash class: once transcript+frame exceeded the trigger, _run_round_compaction fired EVERY round — a light-model call plus a transcript rewrite that collapsed the provider prompt cache to the static floor — because the trigger counted the un-compactable frozen frame (wave3/low: 35/35 rounds, the 400K-char LOW trigger sits below the irreducible low-mode frame; wave1: 24 passes with cached=338,944 streaks). - NECESSITY (compact at all?) stays TOTAL pressure — the frame counts toward the provider window (an immutable core overflowing the window must still trigger) — but is now compared in CALIBRATED real tokens: the char budget's documented token equivalent vs chars/4 x main_loop_token_density (context_fit SSOT: neutral 1.0 cold, measured supersedes — deliberately NOT resolve_token_density's review-pack cold-conservative value, which would demote fresh installs; the v6.80->v6.81 oscillation). On a measured ~1.7x Claude route the trigger now fires at its intended token budget instead of ~1.7x past it; cold stores behave byte-for-byte as before. - UTILITY/rearm (can a pass help; when refire?) is the COMPACTABLE region only (transcript beyond the frozen frame): a pass that could NOT get below the trigger arms a hysteresis in accumulated_usage — no refire until the region grows >=1.2x or 10 rounds pass (context_budget constants). One loud emit_progress disclosure + a typed compaction_hysteresis_armed checkpoint event on arming replace the per-round call/rewrite. - The reactive provider-overflow one-shot low-retry net is untouched, as are manual (pending) and routine compaction branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d6928b95cf
commit
02fecbc019
4 changed files with 223 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue