[eric] agents: mid-turn provider backoff shows a muted retrying pill instead of a dead card, and transient pills survive session status frames

This commit is contained in:
ciregenz
2026-08-09 14:01:11 -07:00
parent 8fdc1fa2f9
commit 5eca1df72d
5 changed files with 91 additions and 2 deletions
@@ -32,6 +32,14 @@ def note_provider_retry(session_id: str, raw: object, turn: TurnState) -> None:
attempt=data.get("attempt"),
delay_ms=delay_ms,
)
# The card sat DEAD through these waits (30s+ with no explanation, ENG-178); a muted pill is honest without reading as an error.
import asyncio
from backend.apps.agents.core.ws_manager import ws_manager
asyncio.get_running_loop().create_task(ws_manager.send_to_session(session_id, "agent:provider_retrying", {
"session_id": session_id,
"attempt": data.get("attempt"),
"delay_ms": delay_ms if isinstance(delay_ms, int) else None,
}))
except Exception:
pass
@@ -69,7 +69,7 @@ import { isShowUiPair, isAskUiPair, extractPendingAskUi } from './tool-ui/showUi
import { composerPlaceholder } from './composerPlaceholder';
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
import ForceStopAgentBar from './ForceStopAgentBar';
import { RateLimitPill } from './shell/RateLimitPill';
import { ProviderRetryPill, RateLimitPill } from './shell/RateLimitPill';
import { ContextRecoveredPill } from './shell/ContextRecoveredPill';
import ChatInput, { ChatInputHandle } from './ChatInput';
import FollowupChips from './FollowupChips';
@@ -2132,6 +2132,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
<RateLimitPill sessionId={session.id} />
<ProviderRetryPill sessionId={session.id} />
<ContextRecoveredPill sessionId={session.id} />
{isGlowing ? (
@@ -3,10 +3,55 @@ 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 AutorenewIcon from '@mui/icons-material/Autorenew';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearRateLimited } from '@/shared/state/agentsSlice';
import { clearProviderRetrying, clearRateLimited } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
/** Mid-turn CLI backoff pill (ENG-178): the provider 500/429'd and the CLI is silently waiting up
* to tens of seconds; without this the card just sits dead. Auto-clears after the announced delay
* plus slack, and each new retry event refreshes it. Same muted grammar as the rate-limit pill. */
export const ProviderRetryPill: React.FC<{ sessionId: string }> = ({ sessionId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const pr = useAppSelector((s) => s.agents.sessions[sessionId]?.provider_retrying);
useEffect(() => {
if (!pr) return;
const ms = Math.min(Math.max((pr.delay_ms ?? 15_000) + 15_000, 10_000), 120_000);
const t = setTimeout(() => dispatch(clearProviderRetrying({ sessionId })), ms);
return () => clearTimeout(t);
}, [pr, sessionId, dispatch]);
const label = pr?.attempt ? `Provider busy, retrying (attempt ${pr.attempt})` : 'Provider busy, retrying';
const lastLabel = useRef(label);
if (pr) lastLabel.current = label;
return (
<Fade in={!!pr} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
title="The AI provider had a hiccup; the agent is waiting it out and will continue 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,
}}
>
<AutorenewIcon sx={{ fontSize: 14, animation: 'osw-retry-spin 1.6s linear infinite', '@keyframes osw-retry-spin': { to: { transform: 'rotate(360deg)' } } }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>{lastLabel.current}</Typography>
</Box>
</Fade>
);
};
// 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();
+23
View File
@@ -118,6 +118,7 @@ export interface AgentSession {
framework_overhead_tokens?: number;
context_overflow?: { reason: string; message: string; at: string } | null;
rate_limited?: { retry_after_s: number | null; at: string } | null;
provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null;
context_recovered?: { at: string } | null;
// Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts.
app_deps_changed?: boolean;
@@ -741,6 +742,9 @@ const agentsSlice = createSlice({
branches: { ...existing?.branches, ...action.payload.branches },
pending_approvals: mergedApprovals,
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
// Renderer-local transient pills; the wire payload never carries them, so a status frame mid-backoff would wipe the "provider busy" pill it exists to explain.
provider_retrying: existing?.provider_retrying ?? null,
rate_limited: existing?.rate_limited ?? null,
};
if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.id)) {
state.trackedNotificationIds.push(action.payload.id);
@@ -1003,6 +1007,23 @@ const agentsSlice = createSlice({
}
},
setProviderRetrying(
state,
action: PayloadAction<{ sessionId: string; attempt: number | null; delayMs: number | null }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.provider_retrying = {
attempt: action.payload.attempt,
delay_ms: action.payload.delayMs,
at: new Date().toISOString(),
};
}
},
clearProviderRetrying(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.provider_retrying = null;
},
clearRateLimited(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.rate_limited = null;
@@ -1489,6 +1510,8 @@ export const {
setContextOverflow,
setRateLimited,
clearRateLimited,
setProviderRetrying,
clearProviderRetrying,
setContextRecovered,
clearContextRecovered,
setAppDepsChanged,
@@ -13,6 +13,7 @@ import {
updateSessionContext,
setContextOverflow,
setRateLimited,
setProviderRetrying,
setContextRecovered,
setAppDepsChanged,
setMcpSuggestions,
@@ -644,6 +645,17 @@ class WebSocketManager {
}
break;
case 'agent:provider_retrying':
// Mid-turn CLI backoff: the provider 500/429'd and the CLI is silently waiting; show the muted pill so the card doesn't read as dead (ENG-178).
if (session_id) {
store.dispatch(setProviderRetrying({
sessionId: session_id,
attempt: typeof data.attempt === 'number' ? data.attempt : null,
delayMs: typeof data.delay_ms === 'number' ? data.delay_ms : null,
}));
}
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) {