Files

224 lines
11 KiB
Python

"""Token accounting + the context-ratio compaction trigger, lifted out of the agent loop.
Both operate on a passed AgentSession (no manager state). emit_context_update writes the
live token counts onto the session and broadcasts them to the UI; maybe_compact decides,
from the same input_tokens/context_window ratio, whether to mark history for trimming.
Compaction here only MARKS (sets compacted_through_msg_id); it never mutates
session.messages, the originals stay for the UI drawer and only the history sent to the SDK
is trimmed downstream (see backend/CLAUDE.md: "compaction must actually trim, not just mark")."""
import os
import logging
from typing import Dict, Optional
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
from backend.apps.agents.manager.streaming.state import TurnState
logger = logging.getLogger(__name__)
@typechecked
def effective_window(session: AgentSession) -> int:
"""The window every budget here reasons about, so a squeeze drill scales US and the CLI from
the same number and the race between the two compactions stays the production one (ENG-418)."""
from backend.apps.agents.core.fault_injection import squeezed_context_window
return squeezed_context_window() or max(1, session.context_window)
@typechecked
def compact_ceiling_tokens(session: AgentSession) -> int:
"""The absolute ceiling, with a drill override.
The mid-turn breaker is otherwise only reachable by paying for a genuine 180K-token turn,
which in practice meant it was never drilled at all: three attempts to fire it cost real
money and still failed. `OSW_COMPACT_CEILING_TOKENS` lowers the bar so the same code path
can be exercised in seconds. Unset everywhere except a drill, and a junk value is ignored
rather than silently trusted."""
raw = os.environ.get("OSW_COMPACT_CEILING_TOKENS", "").strip()
if raw:
try:
override = int(raw)
except ValueError:
return session.compact_abs_ceiling_tokens
if override > 0:
return override
from backend.apps.agents.core.fault_injection import squeezed_context_window
p_squeeze = squeezed_context_window()
if p_squeeze:
# Scaled, not replaced: the ceiling is 18% of a real 1M window, and a drill that kept 180K
# against a 30K window would put the ceiling six times past the wall it is meant to beat.
p_real = max(1, session.context_window)
return max(1, int(session.compact_abs_ceiling_tokens * p_squeeze / p_real))
return session.compact_abs_ceiling_tokens
@typechecked
def compact_trigger_tokens(session: AgentSession) -> int:
"""The token count where compaction fires: the TIGHTER of the pct threshold and the
absolute ceiling (on a 200K window the pct wins at 130K; on a 1M window the ceiling
wins at 180K, not 650K)."""
window = effective_window(session)
ceiling = compact_ceiling_tokens(session)
abs_pct = min(1.0, ceiling / window)
return int(window * min(session.compact_threshold_pct, abs_pct))
# How much a turn must ADD before breaking it is worth the rebuild it costs. Same reasoning as the
# 20K the pre-nudge compaction is gated on: below this, the break spends more than it reclaims.
MIN_TURN_GROWTH_TOKENS = 20_000
CONTINUATION_PROMPT = (
"Continue the task exactly where you left off. Your earlier progress in this chat is "
"summarized above; do not redo completed steps, pick up at the next unfinished one."
)
@typechecked
def maybe_break_midturn(session: AgentSession, turn: TurnState, msg_usage: Dict) -> bool:
"""Mid-turn context breaker: one giant turn (dozens of tool calls off a single ask) can
blow past every turn-boundary wall, so when a request's input usage crosses the compact
trigger MID-turn, end the turn at the next message boundary (the pending_continuation
break the MCPActivate flow already uses), force-compact, and auto-continue fresh.
Live incident: 925K/1M with zero CLI compact_boundary events, task abandoned mid-way."""
try:
total = (
int(msg_usage.get("input_tokens") or 0)
+ int(msg_usage.get("cache_creation_input_tokens") or 0)
+ int(msg_usage.get("cache_read_input_tokens") or 0)
)
except Exception:
return False
if total <= 0:
# No usage on THIS message, which on the Anthropic lane is ordinary: mid-stream assistant
# messages carry output usage only, and a later one carries the real input count. Reporting
# here claimed the whole turn was unprotected on turns that were fine, and a liveness signal
# that cries wolf hides the case it exists for. The honest claim is only available at turn
# end, so report_usage_liveness() makes it there (ENG-391, corrected ENG-418).
return False
# Keep the session's counter honest mid-turn: a broken turn never gets its ResultMessage accounting, and the next pre-send guard reads this.
turn.saw_usable_usage = True
session.tokens["input"] = total
turn.last_step_input = total
p_trigger = compact_trigger_tokens(session)
# Once per turn, say the guard is watching and what it is watching for. "The valve did not fire"
# has three indistinguishable causes from outside (no usage at all, never eligible because the
# turn started over the trigger, or simply never crossed), and each is a different bug. One line
# per turn separates them, and it is the liveness signal this class has been missing.
if not turn.usage_seen_reported:
turn.usage_seen_reported = True
turn.first_input_reading = total
logger.info(
"[context-break] session %s: watching, first reading %d against trigger %d (window %d)",
getattr(session, "id", "?"), total, p_trigger, effective_window(session),
)
if total < p_trigger:
turn.saw_input_below_trigger = True
return False
if turn.context_break_fired:
return False
# A turn is breakable when it CROSSED the trigger, or when it has GROWN materially past where it
# started. The second half is the one that was missing, and it is not an edge case: measured live
# 2026-08-28, the first usage reading a turn ever delivers was 94,404 against a 45,000 trigger,
# so `saw_input_below_trigger` was never set and the breaker sat out the entire turn. In
# production that is every long chat and every resumed session near its ceiling -- exactly the
# 925K/1M blowout with no compact boundary that this guard was written for (ENG-418).
# This costs a REBUILD, and rebuild frequency is the subscription lane's real risk, so the
# once-per-turn latch below is what keeps the trade honest: at most one break per turn.
p_grew = total - turn.first_input_reading >= MIN_TURN_GROWTH_TOKENS
if not (turn.saw_input_below_trigger or p_grew):
return False
# The anti-loop, and the reason growth is safe to act on: a rebuild that failed to shrink lands
# back at or above the last break, and breaking it again would rebuild forever. It must RUN.
if session.last_break_input_tokens and turn.first_input_reading >= session.last_break_input_tokens:
logger.warning(
"[context-break] session %s: the last break rebuilt to %d, no smaller than the %d it "
"broke at, so this turn runs unbroken rather than looping",
getattr(session, "id", "?"), turn.first_input_reading, session.last_break_input_tokens,
)
return False
turn.context_break_fired = True
session.midturn_breaks += 1
session.last_break_input_tokens = total
maybe_compact(session, force=True)
session.needs_fresh_session = True
session.pending_continuation = True
session.pending_continuation_prompt = CONTINUATION_PROMPT
return True
@typechecked
def report_usage_liveness(session: AgentSession, turn: TurnState) -> bool:
"""At turn end, say out loud if the breaker never had a number to work with.
On the codex/GPT lane assistant messages NEVER carry usage (it arrives only on the
ResultMessage), so the breaker is inert for that whole session and one giant turn can run to
the context ceiling with nothing watching. A guard may not disable itself in silence, and this
is the only point where "never" is a fact rather than a guess."""
if turn.saw_usable_usage or turn.usage_absence_reported:
return False
turn.usage_absence_reported = True
logger.warning(
"[context-break] session %s on model %s sent no per-message usage for the WHOLE turn, so "
"the mid-turn context breaker never ran; that turn was unprotected against a single-turn "
"context blowout (ENG-391)",
getattr(session, "id", "?"), getattr(session, "model", "?"),
)
return True
@typechecked
def maybe_compact(session: AgentSession, force: bool = False) -> bool:
"""Mark history for compaction when ctx_used_pct >= compact_threshold_pct (or force).
Returns True if a NEW summary boundary was set. Summarizes everything up to (but not
including) the last 6 messages so recent intent stays visible to the model. Never
touches session.messages."""
if not force and session.tokens.get("input", 0) < compact_trigger_tokens(session):
return False
msgs = get_branch_messages(session)
if len(msgs) < 4:
return False
cutoff = max(0, len(msgs) - 6)
if cutoff == 0:
return False
last_id = msgs[cutoff - 1].id
if session.compacted_through_msg_id == last_id and not force:
return False
session.compacted_through_msg_id = last_id
return True
@typechecked
async def emit_context_update(
session_id: str,
session: AgentSession,
*,
input_tokens: Optional[int] = None,
output_tokens: Optional[int] = None,
cache_read_tokens: int = 0,
cache_read_pct: float = 0.0,
) -> None:
"""Persist the live token counts onto the session and broadcast the context-usage meter
to the UI. When input/output aren't supplied, the session's current counts are reused."""
if input_tokens is None:
input_tokens = int(session.tokens.get("input", 0) or 0)
if output_tokens is None:
output_tokens = int(session.tokens.get("output", 0) or 0)
session.tokens["input"] = input_tokens
session.tokens["output"] = output_tokens
ctx_window = max(1, getattr(session, "context_window", 0) or 200_000)
await ws_manager.send_to_session(session_id, "agent:context_update", {
"session_id": session_id,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_tokens": cache_read_tokens,
"cache_read_pct": cache_read_pct,
"ctx_used_pct": round(input_tokens / ctx_window, 4) if input_tokens else 0.0,
"context_window": ctx_window,
"framework_overhead_tokens": session.framework_overhead_tokens,
"active_mcps": list(session.active_mcps),
})