mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user