mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] agents: a policy-block failover borrows the user's API key for one ask, not forever (ENG-383)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user