[eric] agents: a policy-block failover borrows the user's API key for one ask, not forever (ENG-383)

This commit is contained in:
ciregenz
2026-08-26 13:14:41 -07:00
parent 2a2ab3a520
commit 492a28a2b4
4 changed files with 77 additions and 1 deletions
+4
View File
@@ -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.
+5
View File
@@ -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
@@ -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)
@@ -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