From 492a28a2b4993b94f55a7132ebd306984f2fcbd8 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 26 Aug 2026 13:14:41 -0700 Subject: [PATCH] [eric] agents: a policy-block failover borrows the user's API key for one ask, not forever (ENG-383) --- backend/apps/agents/core/models.py | 4 ++ backend/apps/agents/manager/Messaging.py | 5 ++ .../agents/manager/run/handle_run_error.py | 5 +- .../tests/test_lane_failover_is_borrowed.py | 64 +++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_lane_failover_is_borrowed.py diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index f94bbf6a..ba89761f 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -158,6 +158,10 @@ class AgentSession(BaseModel): history_prefix_once: Optional[Literal["summary", "none"]] = None # What the LAST spawned turn actually carried, so a block can tell a recap-caused refusal from a plain one. history_prefix_sent: Literal["minimal", "summary", "none"] = "none" + # The subscription model a policy block borrowed the user's own API key away from, restored the + # moment they send again. Failing over is for finishing THE CURRENT ASK; leaving the chat on a + # metered key forever would bill them per token with one line said about it, once (ENG-383). + lane_failover_from: Optional[str] = None # Consecutive dirty deaths this session was MID-TURN for; the crash auto-resume breaker (hermes #30719 pairing: auto-resume must never outrun its circuit breaker). crash_interrupt_count: int = 0 # Outage rounds spent on this ask: the in-turn ladder covers only 335s, and the work is checkpointed, so a longer drop is waited out rather than ending the task. diff --git a/backend/apps/agents/manager/Messaging.py b/backend/apps/agents/manager/Messaging.py index e460dec3..802690c7 100644 --- a/backend/apps/agents/manager/Messaging.py +++ b/backend/apps/agents/manager/Messaging.py @@ -158,6 +158,11 @@ class Messaging(AgentManagerProtocol): session.auth_retry_used = False # The repeat-quit floor and the vanishing-quit rule key on this; one false positive used to arm both for the session's life (ENG-364). session.empty_finish_total = 0 + # The borrowed API key was for one ask, and this is a new one; back to the lane they chose. + if session.lane_failover_from: + logger.info(f"lane failover over for {session_id}: {session.model} -> {session.lane_failover_from}") + session.model = session.lane_failover_from + session.lane_failover_from = None # A human is here and driving, so an earlier outage stops counting against the next one. session.reconnect_attempts = 0 session.awaiting_reconnect = False diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index d28bb453..0146cdd5 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -289,12 +289,15 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, if p_twin: p_from = session.model session.model = p_twin + session.lane_failover_from = p_from session.needs_fresh_session = True session.pending_continuation = True session.pending_continuation_prompt = "Continue where you left off and finish the task, then answer in plain text." p_notice = Message( role="system", - content="Claude declined this request on your subscription; continuing on your Anthropic API key.", + content=("Claude declined this request on your subscription, so this answer is " + "finishing on your Anthropic API key (billed to that key). Your next " + "message goes back on the subscription."), branch_id=session.active_branch_id, ) absorb_repeat_card(session, p_notice) diff --git a/backend/tests/test_lane_failover_is_borrowed.py b/backend/tests/test_lane_failover_is_borrowed.py new file mode 100644 index 00000000..f5a4db40 --- /dev/null +++ b/backend/tests/test_lane_failover_is_borrowed.py @@ -0,0 +1,64 @@ +"""A policy-block failover borrows the user's API key for ONE ask, then gives it back. + +ENG-383 shipped `session.model = p_twin` as a PERMANENT rewrite with a single notice line. After +that one message, every later turn in the chat spent the user's metered Anthropic key and nothing +said so again: row 2 on the ladder, silent money spend. It is also the ENG-386 shape, a heal that +persists a model the user never chose. +""" + +from backend.apps.agents.core.models import AgentSession + +ERR = "backend/apps/agents/manager/run/handle_run_error.py" +MSG = "backend/apps/agents/manager/Messaging.py" + + +def p_session(**kw) -> AgentSession: + return AgentSession(id="s1", name="chat", title="chat", **kw) + + +def test_the_session_remembers_the_lane_it_came_from(): + s = p_session(model="cc/claude-opus-5") + assert s.lane_failover_from is None, "no borrow in progress by default" + s.lane_failover_from = s.model + s.model = "claude-opus-5-api" + assert s.lane_failover_from == "cc/claude-opus-5" + + +def test_the_failover_records_where_to_go_back_to(): + src = open(ERR).read() + i = src.index("session.model = p_twin") + body = src[i:i + 200] + assert "session.lane_failover_from = p_from" in body, \ + "a switch with no way back is a permanent spend" + + +def test_the_next_user_message_returns_the_session_to_its_own_lane(): + src = open(MSG).read() + i = src.index("if session.lane_failover_from:") + body = src[i:i + 400] + assert "session.model = session.lane_failover_from" in body + assert "session.lane_failover_from = None" in body, "the borrow must not be able to fire twice" + # It has to sit inside the human-message branch, not fire on a hidden harness send, or the + # continuation that the failover exists to run would be yanked back mid-ask. + i_human = src.index("if not hidden:") + i_reset = src.index("session.empty_finish_total = 0") + assert i_human < i < i_reset + 600, "it must be part of the real-user-message reset block" + + +def test_the_notice_says_it_is_metered_and_temporary(): + import re + src = open(ERR).read() + i = src.index("Claude declined this request on your subscription") + # Adjacent string literals are joined across source lines; read the sentence, not the layout. + notice = re.sub(r'"\s*\n\s*"', "", src[i:i + 400]) + assert "billed to that key" in notice, "their own key costs them money; say so" + assert "next message goes back on the subscription" in notice, "and say it is not permanent" + + +def test_a_hidden_continuation_cannot_end_the_borrow(): + # The continuation IS the ask the failover was for. If a hidden send restored the model, the + # retry would go straight back to the lane that just refused it. + src = open(MSG).read() + i = src.index("if session.lane_failover_from:") + guard = src.rindex("if not hidden:", 0, i) + assert src.count("if not hidden:", guard, i) == 1