[eric] agents: a context overflow now compacts and retries once instead of dying, and never fakes a completed

This commit is contained in:
ciregenz
2026-08-03 15:24:11 -07:00
parent 374666fbd9
commit 9eef9d6a1d
8 changed files with 168 additions and 60 deletions
+9 -5
View File
@@ -181,14 +181,17 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
turn.stream_text_msg_id = None
turn.stream_text_accum = ""
except Exception as e:
from backend.apps.agents.core.error_classify import is_context_pressure_death
from backend.apps.agents.core.error_classify import is_context_overflow_error, is_context_pressure_death
p_stderr_tail = "\n".join(p_stderr_buffer[-50:])
if not context_valve_retry and is_context_pressure_death(e, turn.compact_boundaries, extra_text=p_stderr_tail):
# Pressure-release valve: the CLI compacted this turn and still died (its "autocompact is thrashing" giving-up class). Its resume transcript is beyond saving, but ours isn't: rebuild from the local mirror via the proven fresh-session recap path and transparently re-run the turn ONCE.
p_overflow = is_context_overflow_error(e, extra_text=p_stderr_tail)
if not context_valve_retry and (p_overflow or is_context_pressure_death(e, turn.compact_boundaries, extra_text=p_stderr_tail)):
# Pressure-release valve, two entry shapes: the CLI compacted this turn and still died (autocompact thrash), or the provider rejected the query outright as over the context window. Either way the CLI's resume transcript is beyond saving, but ours isn't: rebuild from the local mirror via the proven fresh-session recap path and transparently re-run the turn ONCE.
logger.warning(
f"Agent {session_id}: context-pressure death after "
f"Agent {session_id}: {'context overflow' if p_overflow else 'context-pressure death'} after "
f"{turn.compact_boundaries} compact boundaries; one fresh-session recap retry"
)
# The recap rebuild trims at compacted_through_msg_id; an overflow can hit before the proactive threshold ever fired, so force a cutoff or the rebuilt prompt is full history again.
self.maybe_compact(session, force=True)
session.needs_fresh_session = True
if turn.stream_text_msg_id:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
@@ -210,9 +213,10 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
logger.debug("context_recovered broadcast failed", exc_info=True)
try:
from backend.apps.service.client import submit_diagnostic
from backend.apps.agents.core.error_classify import redact_for_telemetry
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
submit_diagnostic({
"kind": "context_pressure_valve",
"trigger": "overflow" if p_overflow else "pressure_death",
"session_id": session_id,
"model": session.model,
"compact_boundaries": turn.compact_boundaries,
+28 -35
View File
@@ -5,28 +5,6 @@ import anthropic
import httpx
from typeguard import typechecked
# Secret shapes that must never ride along when we ship a stderr tail or an error string to telemetry. own_key mode means the subprocess stderr can echo the user's OWN provider key, so this scrub is the wall between a diagnostic and a key leak; over-redacting is fine, leaking is not.
P_TELEMETRY_SECRET_PATTERNS = (
re.compile(r"sk-ant-[A-Za-z0-9_\-]{12,}"),
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
re.compile(r"AIza[A-Za-z0-9_\-]{20,}"),
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}"),
re.compile(r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|authorization)\b[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9._\-]{6,}"),
)
def redact_for_telemetry(text: str, *, limit: int = 2000) -> str:
"""Scrub secret-shaped substrings, then keep the tail (where the real error
lands), bounded so a runaway log can't bloat the payload. Every raw
error/stderr string goes through here before it leaves the machine."""
if not text:
return ""
for pat in P_TELEMETRY_SECRET_PATTERNS:
text = pat.sub("[redacted]", text)
return text[-limit:]
# Patterns that indicate an upstream transient problem (overload / rate limit / infra blip), safe to silently retry with backoff. Checked against the stringified exception from claude_agent_sdk / Claude CLI.
TRANSIENT_CAPACITY_PATTERNS = re.compile(
r"(?:\b(?:429|500|502|503|504|529)\b"
@@ -90,6 +68,31 @@ def is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
))
@typechecked
def is_context_overflow_error(exc: BaseException, extra_text: str = "") -> bool:
"""The context-window overflow family across providers: Anthropic's 'prompt is too
long' 400 and long-context tier gate, OpenAI's 'maximum context length' /
'context_length_exceeded' / 'request too large', Gemini's 'input token count exceeds'.
Gates the reactive compact-and-retry valve in run_agent_loop; a misfire costs one
bounded fresh-session recap retry, a miss means today's terminal error card.
"""
if is_long_context_error(exc, extra_text):
return True
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
return bool(re.search(
r"prompt\s+is\s+too\s+long"
r"|maximum\s+context\s+length"
r"|context[_\s-]?length[_\s-]?exceeded"
r"|input\s+token\s+count[^.\n]{0,40}exceeds"
r"|exceeds?\s+the\s+(?:maximum\s+)?(?:context|token)\s+(?:window|limit)"
r"|request\s+too\s+large",
combined,
re.IGNORECASE,
))
@typechecked
def is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
"""True when the cloud says the machine's free runs are spent (a 402 with
@@ -215,6 +218,9 @@ def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> boo
combined = f"{exc!s}\n{extra_text}".strip()
if combined and NON_TRANSIENT_PATTERNS.search(combined):
return False
# An overflow can arrive dressed as a 429 ("request too large"); retrying the identical oversized request is guaranteed futile, the valve owns it.
if is_context_overflow_error(exc, extra_text):
return False
# Ahead of the empty-string bail on purpose: what the exception IS doesn't depend on whether it bothered to say anything.
if isinstance(exc, P_TRANSIENT_EXC_TYPES):
return True
@@ -284,16 +290,3 @@ def is_context_pressure_death(exc: BaseException, compact_boundaries: int, extra
return True
@typechecked
def extract_reset_hint(text: str) -> str:
"""Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of
a provider usage error so we can tell the user when their limit comes back.
"""
if not text:
return ""
m = re.search(
r"(?:try\s+again|resets?|reset)\s+((?:in|at|after)\s+[^.\n)]{1,40})",
text,
re.IGNORECASE,
)
return m.group(1).strip() if m else ""
@@ -0,0 +1,18 @@
import re
from typeguard import typechecked
@typechecked
def extract_reset_hint(text: str) -> str:
"""Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of
a provider usage error so we can tell the user when their limit comes back.
"""
if not text:
return ""
m = re.search(
r"(?:try\s+again|resets?|reset)\s+((?:in|at|after)\s+[^.\n)]{1,40})",
text,
re.IGNORECASE,
)
return m.group(1).strip() if m else ""
@@ -0,0 +1,25 @@
import re
from typeguard import typechecked
# Secret shapes that must never ride along when we ship a stderr tail or an error string to telemetry. own_key mode means the subprocess stderr can echo the user's OWN provider key, so this scrub is the wall between a diagnostic and a key leak; over-redacting is fine, leaking is not.
P_TELEMETRY_SECRET_PATTERNS = (
re.compile(r"sk-ant-[A-Za-z0-9_\-]{12,}"),
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
re.compile(r"AIza[A-Za-z0-9_\-]{20,}"),
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}"),
re.compile(r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|authorization)\b[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9._\-]{6,}"),
)
@typechecked
def redact_for_telemetry(text: str, *, limit: int = 2000) -> str:
"""Scrub secret-shaped substrings, then keep the tail (where the real error
lands), bounded so a runaway log can't bloat the payload. Every raw
error/stderr string goes through here before it leaves the machine."""
if not text:
return ""
for pat in P_TELEMETRY_SECRET_PATTERNS:
text = pat.sub("[redacted]", text)
return text[-limit:]
@@ -12,17 +12,18 @@ from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.settings.settings import load_settings
from backend.apps.agents.manager.streaming.state import TurnState
from backend.apps.agents.core.error_classify import (
is_context_overflow_error,
is_long_context_error,
is_transient_capacity_error,
is_free_trial_exhausted,
is_out_of_tokens,
extract_reset_hint,
is_auth_error,
is_cli_binary_missing,
is_unknown_model_error,
parse_retry_after,
redact_for_telemetry,
)
from backend.apps.agents.core.extract_reset_hint import extract_reset_hint
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
logger = logging.getLogger(__name__)
@@ -37,32 +38,25 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
p_stderr_tail = "\n".join(p_stderr_buffer[-50:])
except Exception:
p_stderr_tail = ""
# If we already streamed a substantive assistant response this turn, the user got their answer; the error fired on a subsequent step (title gen, follow-up tool turn, etc.). Don't blast a "context exceeded" card over a completed reply.
p_streamed_substantive = bool(turn.stream_text_msg_id) and turn.current_turn_emitted
if p_streamed_substantive and is_long_context_error(e, extra_text=p_stderr_tail):
# Mark the session completed (not error), keep the assistant reply visible, and skip the overflow card. The next user turn will properly hit the pre-send guard if the chat is still over cap.
session.status = "completed"
if turn.stream_text_msg_id:
try:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": turn.stream_text_msg_id,
})
except Exception:
pass
return
if is_long_context_error(e, extra_text=p_stderr_tail):
# No completed-mask here anymore: current_turn_emitted stays True until a ResultMessage lands, so the old "already answered" early-return fired on every MID-TASK death (models narrate between tool calls) and converted a dead run into a fake "completed". Reaching this handler with an overflow means the valve's compact-and-retry already failed once; the user must see the card.
if is_context_overflow_error(e, extra_text=p_stderr_tail):
p_tier_gate = is_long_context_error(e, extra_text=p_stderr_tail)
friendly_msg = (
"This conversation has grown too large for your account's "
"standard context window. Long-context requests require an "
"upgraded tier, switch to Chat mode or start a fresh chat "
"to continue."
) if p_tier_gate else (
"This conversation outgrew the model's context window, and "
"automatic compaction couldn't shrink it enough. Start a fresh "
"chat (your recent context carries over) or switch to a model "
"with a larger window."
)
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
session.messages.append(error_msg)
p_ovf_payload = {
"session_id": session_id,
"reason": "long_context_required",
"reason": "long_context_required" if p_tier_gate else "context_overflow",
"message": friendly_msg,
"model": session.model,
"provider": session.provider,
+1 -1
View File
@@ -349,7 +349,7 @@ def p_report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> N
every other telemetry string. Never raises."""
logger.warning("9Router start failed (%s)", reason)
try:
from backend.apps.agents.core.error_classify import redact_for_telemetry
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
from backend.apps.service.client import submit_diagnostic
payload: dict[str, Any] = {
"kind": "9router_start_failed",
@@ -102,6 +102,54 @@ def test_no_valve_without_compaction_churn(monkeypatch) -> None:
assert [m for m in session.messages if m.role == "system" and str(m.content).startswith("Error:")]
def test_overflow_valve_retries_with_forced_compaction(monkeypatch) -> None:
from backend.apps.agents.core.models import Message
from backend.apps.agents.manager.streaming.handle_result_message import TurnResultError
session = p_seed_session()
# Enough history that the forced compact mark has something to cut (keeps last 6).
for i in range(10):
session.messages.append(Message(role="user" if i % 2 == 0 else "assistant", content=f"m{i}", branch_id=session.active_branch_id))
calls: list = []
async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs,
turn, thinking, stderr, resolved_model, api_type,
global_settings, force_respawn=False):
calls.append({"needs_fresh": sess.needs_fresh_session})
# Zero compact boundaries on purpose: an overflow can hit before autocompact ever fired.
if len(calls) == 1:
raise TurnResultError("Prompt is too long")
p_install_run_fakes(monkeypatch, fake_run_turn)
asyncio.run(agent_manager.run_agent_loop(session.id, "hello"))
assert len(calls) == 2
assert calls[1]["needs_fresh"] is True
assert session.compacted_through_msg_id is not None
assert session.status == "completed"
assert not [m for m in session.messages if m.role == "system" and str(m.content).startswith("Error:")]
def test_overflow_on_retry_surfaces_the_card_not_a_fake_completed(monkeypatch) -> None:
session = p_seed_session()
calls: list = []
async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs,
turn, thinking, stderr, resolved_model, api_type,
global_settings, force_respawn=False):
calls.append(1)
# Fake mid-task streaming: the old handle_run_error early-return keyed on exactly this and marked the dead run "completed".
turn.stream_text_msg_id = "msg-1"
turn.current_turn_emitted = True
raise Exception("Error code: 429 - extra usage is required for long context")
p_install_run_fakes(monkeypatch, fake_run_turn)
asyncio.run(agent_manager.run_agent_loop(session.id, "hello"))
assert len(calls) == 2
assert session.status == "error"
assert [m for m in session.messages if m.role == "system" and "context window" in str(m.content)]
def test_valve_never_loops(monkeypatch) -> None:
session = p_seed_session()
calls: list = []
+27 -1
View File
@@ -13,11 +13,12 @@ from backend.apps.agents.core.error_classify import (
capacity_retry_wait,
is_auth_error,
is_cli_binary_missing,
is_context_overflow_error,
is_free_trial_exhausted,
is_transient_capacity_error,
is_unknown_model_error,
redact_for_telemetry,
)
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
from backend.apps.agents.core.first_real_exception import first_real_exception
# Verbatim field strings from prod analytics (2026-07): the exact shapes users hit.
@@ -89,6 +90,31 @@ def test_cli_missing_matches_sdk_exception_type():
assert is_cli_binary_missing(CLINotFoundError("whatever text"))
# The overflow family across providers; each of these shapes used to kill the run with either a raw error card or (worse) a fake "completed".
P_OVERFLOW_SHAPES = (
"API Error: 400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long: 214384 tokens > 200000 maximum\"}}",
"Error code: 429 - extra usage is required for long context",
"This model's maximum context length is 128000 tokens. However, your messages resulted in 131074 tokens.",
"Error code: 400 - {'error': {'code': 'context_length_exceeded'}}",
"The input token count (1048577) exceeds the maximum number of tokens allowed (1048576).",
"Error code: 429 - Request too large for gpt-4o on tokens per min (TPM)",
)
def test_overflow_family_is_claimed_and_never_retried_verbatim():
for s in P_OVERFLOW_SHAPES:
e = Exception(s)
assert is_context_overflow_error(e), s
# Retrying the identical oversized request is guaranteed futile; the valve owns it.
assert not is_transient_capacity_error(e), s
assert capacity_retry_wait(e, 0) is None, s
def test_overflow_does_not_claim_ordinary_errors():
for s in (P_FIELD_POOL_BUSY, P_FIELD_CLI_MISSING, "529 overloaded, try again shortly", "401 invalid x-api-key"):
assert not is_context_overflow_error(Exception(s)), s
def test_first_real_exception_unwraps_nested_groups():
boom = ValueError("boom")
group = BaseExceptionGroup(