mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[eric] throttle: emit rate_limited on exhausted backoff + muted auto-clearing pill (no error card)
(cherry picked from commit 03a9233b860683ae0f9d15b9e9efda32763c7d41)
This commit is contained in:
@@ -35,6 +35,7 @@ from backend.apps.agents.core.error_classify import (
|
||||
_is_long_context_error,
|
||||
_is_transient_capacity_error,
|
||||
_is_unknown_model_error,
|
||||
parse_retry_after,
|
||||
redact_for_telemetry,
|
||||
)
|
||||
from backend.apps.agents.manager.session.session_store import (
|
||||
@@ -3238,6 +3239,25 @@ class AgentManager:
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
|
||||
elif _is_transient_capacity_error(e, extra_text=_stderr_tail):
|
||||
# A genuine throttle (429/overload/capacity) that already burned
|
||||
# the whole silent-backoff budget (the only way one reaches here).
|
||||
# It's a limit, not a failure, so don't append a system-message
|
||||
# card; emit a transient signal for the muted pill and mark the
|
||||
# turn completed so it doesn't read as an error.
|
||||
session.status = "completed"
|
||||
if stream_text_msg_id:
|
||||
try:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": stream_text_msg_id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await ws_manager.send_to_session(session_id, "agent:rate_limited", {
|
||||
"session_id": session_id,
|
||||
"retry_after_s": parse_retry_after(e, _stderr_tail),
|
||||
})
|
||||
elif _is_free_trial_exhausted(e, extra_text=_stderr_tail):
|
||||
# Free runs spent. Flip back to own_key and show a friendly
|
||||
# "connect a model" upsell instead of a raw 402.
|
||||
|
||||
@@ -173,6 +173,24 @@ def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None:
|
||||
"""Best-effort seconds-until-retry pulled from a throttle error; None if the
|
||||
upstream didn't say. Only used to label the rate-limit pill, so a miss just
|
||||
means the pill shows no countdown, never anything load-bearing."""
|
||||
combined = f"{exc!s}\n{extra_text}"
|
||||
# "1m 59s" / "2m" / "45s" (reset-window phrasing Codex/Anthropic use).
|
||||
m = re.search(r"\b(?:(\d{1,2})\s*m(?:in)?)?\s*(\d{1,3})\s*s(?:ec)?\b", combined, re.IGNORECASE)
|
||||
if m and (m.group(1) or m.group(2)):
|
||||
return int(m.group(1) or 0) * 60 + int(m.group(2) or 0)
|
||||
# "retry-after: 30" / "try again in 2 minutes".
|
||||
m = re.search(r"(?:retry[-\s]?after|try\s+again\s+in)\D{0,8}(\d{1,4})\s*(m|min|minute|s|sec|second)?", combined, re.IGNORECASE)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
unit = (m.group(2) or "s").lower()
|
||||
return n * 60 if unit.startswith("m") else n
|
||||
return None
|
||||
|
||||
|
||||
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
# The Claude CLI's underlying ProcessError stringifies to a generic
|
||||
# "Command failed with exit code 1 / Check stderr output for details";
|
||||
|
||||
@@ -55,6 +55,7 @@ import MessageActionBar from './shell/MessageActionBar';
|
||||
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
|
||||
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
|
||||
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
|
||||
import { RateLimitPill } from './shell/RateLimitPill';
|
||||
import ChatInput, { ChatInputHandle } from './ChatInput';
|
||||
import ContextDrawer from './shell/ContextDrawer';
|
||||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||||
@@ -1860,6 +1861,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
))
|
||||
)}
|
||||
|
||||
<RateLimitPill sessionId={session.id} />
|
||||
|
||||
{isGlowing ? (
|
||||
<Box
|
||||
onClick={(e) => { e.stopPropagation(); onDismissGlow?.(); }}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ScheduleIcon from '@mui/icons-material/Schedule';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearRateLimited } from '@/shared/state/agentsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
// Muted, transient pill shown only after a real provider throttle outlasted the
|
||||
// silent backoff. No card, no red, no CTA; it fades and auto-clears once the
|
||||
// window should have passed. The "why" lives in the hover, not on the surface.
|
||||
export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const rl = useAppSelector((s) => s.agents.sessions[sessionId]?.rate_limited);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rl) return;
|
||||
const ms = Math.min(Math.max(rl.retry_after_s ?? 45, 5), 300) * 1000;
|
||||
const t = setTimeout(() => dispatch(clearRateLimited({ sessionId })), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [rl, sessionId, dispatch]);
|
||||
|
||||
const label = rl?.retry_after_s
|
||||
? `Back ~${(() => {
|
||||
const d = new Date(Date.now() + rl.retry_after_s * 1000);
|
||||
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
})()}`
|
||||
: 'Rate limited';
|
||||
// Hold the last text so the exit fade renders content, not a blank pill.
|
||||
const lastLabel = useRef(label);
|
||||
if (rl) lastLabel.current = label;
|
||||
|
||||
return (
|
||||
<Fade in={!!rl} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
title="Your plan hit its rate limit, it'll resume on its own"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.6,
|
||||
alignSelf: 'flex-start',
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
}}
|
||||
>
|
||||
<ScheduleIcon sx={{ fontSize: 14 }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>{lastLabel.current}</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
@@ -102,6 +102,7 @@ export interface AgentSession {
|
||||
context_window?: number;
|
||||
framework_overhead_tokens?: number;
|
||||
context_overflow?: { reason: string; message: string; at: string } | null;
|
||||
rate_limited?: { retry_after_s: number | null; at: string } | null;
|
||||
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
|
||||
mcp_suggestions_is_vague?: boolean;
|
||||
compacted_through_msg_id?: string | null;
|
||||
@@ -919,6 +920,24 @@ const agentsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
setRateLimited(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; retryAfterS: number | null }>
|
||||
) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) {
|
||||
session.rate_limited = {
|
||||
retry_after_s: action.payload.retryAfterS,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
clearRateLimited(state, action: PayloadAction<{ sessionId: string }>) {
|
||||
const session = state.sessions[action.payload.sessionId];
|
||||
if (session) session.rate_limited = null;
|
||||
},
|
||||
|
||||
clearContextOverflow(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string }>
|
||||
@@ -1425,6 +1444,8 @@ export const {
|
||||
updateSessionCost,
|
||||
updateSessionContext,
|
||||
setContextOverflow,
|
||||
setRateLimited,
|
||||
clearRateLimited,
|
||||
clearContextOverflow,
|
||||
setMcpSuggestions,
|
||||
clearMcpSuggestions,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
updateSessionCost,
|
||||
updateSessionContext,
|
||||
setContextOverflow,
|
||||
setRateLimited,
|
||||
setMcpSuggestions,
|
||||
addBranch,
|
||||
setActiveBranch,
|
||||
@@ -655,6 +656,17 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:rate_limited':
|
||||
// Provider throttle that outlasted the silent backoff. Transient muted
|
||||
// pill, not a card; auto-clears frontend-side.
|
||||
if (session_id) {
|
||||
store.dispatch(setRateLimited({
|
||||
sessionId: session_id,
|
||||
retryAfterS: typeof data.retry_after_s === 'number' ? data.retry_after_s : null,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent:context_status':
|
||||
// Auto-compaction collapsed older turns into a summary. Mirror
|
||||
// compacted_through_msg_id locally so the renderer can drop a
|
||||
|
||||
Reference in New Issue
Block a user