tfrere HF Staff Cursor commited on
Commit
d745ea8
Β·
1 Parent(s): 0a07d96

Fix 4 reported issues: model selector, approval bug, tool timeout, session limit

Browse files

- Model selector: GET/POST /api/config/model + dropdown in header bar
- Approval fix: optimistic UI update after approve/reject (don't wait for WS)
- Tool timeout: 5-min timeout shows "timed out" chip for stuck tools
- Session limit: MAX_SESSIONS_PER_USER increased from 3 to 10

Co-authored-by: Cursor <cursoragent@cursor.com>

backend/routes/agent.py CHANGED
@@ -88,6 +88,38 @@ async def llm_health_check() -> LLMHealthResponse:
88
  )
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  @router.post("/title")
92
  async def generate_title(
93
  request: SubmitRequest, user: dict = Depends(get_current_user)
 
88
  )
89
 
90
 
91
+ AVAILABLE_MODELS = [
92
+ {"id": "anthropic/claude-opus-4-5-20251101", "label": "Claude Opus 4.5", "provider": "anthropic"},
93
+ {"id": "huggingface/novita/deepseek-ai/DeepSeek-V3.1", "label": "DeepSeek V3.1", "provider": "huggingface"},
94
+ {"id": "huggingface/novita/MiniMaxAI/MiniMax-M2.1", "label": "MiniMax M2.1", "provider": "huggingface"},
95
+ ]
96
+
97
+
98
+ @router.get("/config/model")
99
+ async def get_model(user: dict = Depends(get_current_user)) -> dict:
100
+ """Get current model and available models."""
101
+ return {
102
+ "current": session_manager.config.model_name,
103
+ "available": AVAILABLE_MODELS,
104
+ }
105
+
106
+
107
+ @router.post("/config/model")
108
+ async def set_model(
109
+ body: dict, user: dict = Depends(get_current_user)
110
+ ) -> dict:
111
+ """Set the LLM model. Applies to new conversations."""
112
+ model_id = body.get("model")
113
+ if not model_id:
114
+ raise HTTPException(status_code=400, detail="Missing 'model' field")
115
+ valid_ids = {m["id"] for m in AVAILABLE_MODELS}
116
+ if model_id not in valid_ids:
117
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
118
+ session_manager.config.model_name = model_id
119
+ logger.info(f"Model changed to {model_id} by {user.get('username', 'unknown')}")
120
+ return {"model": model_id}
121
+
122
+
123
  @router.post("/title")
124
  async def generate_title(
125
  request: SubmitRequest, user: dict = Depends(get_current_user)
backend/session_manager.py CHANGED
@@ -67,7 +67,7 @@ class SessionCapacityError(Exception):
67
  # Estimated for HF Spaces cpu-basic (2 vCPU, 16 GB RAM).
68
  # Each session uses ~10-20 MB (context, tools, queues, task).
69
  MAX_SESSIONS: int = 50
70
- MAX_SESSIONS_PER_USER: int = 3
71
 
72
 
73
  class SessionManager:
 
67
  # Estimated for HF Spaces cpu-basic (2 vCPU, 16 GB RAM).
68
  # Each session uses ~10-20 MB (context, tools, queues, task).
69
  MAX_SESSIONS: int = 50
70
+ MAX_SESSIONS_PER_USER: int = 10
71
 
72
 
73
  class SessionManager:
frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -18,6 +18,14 @@ interface ToolCallGroupProps {
18
  tools: TraceLog[];
19
  }
20
 
 
 
 
 
 
 
 
 
21
  // ── Status icon based on tool state ─────────────────────────────────
22
  function StatusIcon({ log }: { log: TraceLog }) {
23
  // Awaiting approval
@@ -28,6 +36,10 @@ function StatusIcon({ log }: { log: TraceLog }) {
28
  if (log.approvalStatus === 'rejected') {
29
  return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
30
  }
 
 
 
 
31
  // Running (not completed yet)
32
  if (!log.completed) {
33
  return (
@@ -56,6 +68,7 @@ function StatusIcon({ log }: { log: TraceLog }) {
56
  function statusLabel(log: TraceLog): string | null {
57
  if (log.approvalStatus === 'pending') return 'awaiting approval';
58
  if (log.approvalStatus === 'rejected') return 'rejected';
 
59
  if (!log.completed) return 'running';
60
  return null;
61
  }
@@ -63,6 +76,7 @@ function statusLabel(log: TraceLog): string | null {
63
  function statusColor(log: TraceLog): string {
64
  if (log.approvalStatus === 'pending') return 'var(--accent-yellow)';
65
  if (log.approvalStatus === 'rejected') return 'var(--accent-red)';
 
66
  return 'var(--accent-yellow)';
67
  }
68
 
@@ -213,7 +227,7 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
213
  async (toolCallId: string, approved: boolean, feedback?: string) => {
214
  if (!activeSessionId) return;
215
  try {
216
- await apiFetch('/api/approve', {
217
  method: 'POST',
218
  body: JSON.stringify({
219
  session_id: activeSessionId,
@@ -224,7 +238,17 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
224
  }],
225
  }),
226
  });
227
- // The WebSocket will send back tool_output events which will update the trace
 
 
 
 
 
 
 
 
 
 
228
  } catch (e) {
229
  logger.error('Approval failed:', e);
230
  }
 
18
  tools: TraceLog[];
19
  }
20
 
21
+ /** Check if a running tool has been stuck for too long (5 minutes). */
22
+ const TOOL_TIMEOUT_MS = 5 * 60 * 1000;
23
+ function isTimedOut(log: TraceLog): boolean {
24
+ if (log.completed || log.approvalStatus === 'pending') return false;
25
+ const elapsed = Date.now() - new Date(log.timestamp).getTime();
26
+ return elapsed > TOOL_TIMEOUT_MS;
27
+ }
28
+
29
  // ── Status icon based on tool state ─────────────────────────────────
30
  function StatusIcon({ log }: { log: TraceLog }) {
31
  // Awaiting approval
 
36
  if (log.approvalStatus === 'rejected') {
37
  return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
38
  }
39
+ // Timed out
40
+ if (isTimedOut(log)) {
41
+ return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
42
+ }
43
  // Running (not completed yet)
44
  if (!log.completed) {
45
  return (
 
68
  function statusLabel(log: TraceLog): string | null {
69
  if (log.approvalStatus === 'pending') return 'awaiting approval';
70
  if (log.approvalStatus === 'rejected') return 'rejected';
71
+ if (isTimedOut(log)) return 'timed out';
72
  if (!log.completed) return 'running';
73
  return null;
74
  }
 
76
  function statusColor(log: TraceLog): string {
77
  if (log.approvalStatus === 'pending') return 'var(--accent-yellow)';
78
  if (log.approvalStatus === 'rejected') return 'var(--accent-red)';
79
+ if (isTimedOut(log)) return 'var(--muted-text)';
80
  return 'var(--accent-yellow)';
81
  }
82
 
 
227
  async (toolCallId: string, approved: boolean, feedback?: string) => {
228
  if (!activeSessionId) return;
229
  try {
230
+ const res = await apiFetch('/api/approve', {
231
  method: 'POST',
232
  body: JSON.stringify({
233
  session_id: activeSessionId,
 
238
  }],
239
  }),
240
  });
241
+
242
+ if (res.ok) {
243
+ // Optimistic update: immediately reflect approval status in the UI
244
+ const { updateTraceLog, updateCurrentTurnTrace, setProcessing } = useAgentStore.getState();
245
+ updateTraceLog(toolCallId, '', {
246
+ approvalStatus: approved ? 'approved' : 'rejected',
247
+ completed: !approved, // Rejected tools are done; approved ones will run
248
+ });
249
+ updateCurrentTurnTrace(activeSessionId);
250
+ if (approved) setProcessing(true);
251
+ }
252
  } catch (e) {
253
  logger.error('Approval failed:', e);
254
  }
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useEffect } from 'react';
2
  import {
3
  Avatar,
4
  Box,
@@ -7,6 +7,8 @@ import {
7
  IconButton,
8
  Alert,
9
  AlertTitle,
 
 
10
  useMediaQuery,
11
  useTheme,
12
  } from '@mui/material';
@@ -48,6 +50,36 @@ export default function AppLayout() {
48
  const theme = useTheme();
49
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  const isResizing = useRef(false);
52
 
53
  const handleMouseMove = useCallback((e: MouseEvent) => {
@@ -302,6 +334,36 @@ export default function AppLayout() {
302
  </Box>
303
 
304
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  <IconButton
306
  onClick={toggleTheme}
307
  size="small"
 
1
+ import { useCallback, useRef, useEffect, useState } from 'react';
2
  import {
3
  Avatar,
4
  Box,
 
7
  IconButton,
8
  Alert,
9
  AlertTitle,
10
+ Select,
11
+ MenuItem,
12
  useMediaQuery,
13
  useTheme,
14
  } from '@mui/material';
 
50
  const theme = useTheme();
51
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
52
 
53
+ // ── Model selector state ──────────────────────────────────────────
54
+ const [currentModel, setCurrentModel] = useState('');
55
+ const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
56
+
57
+ useEffect(() => {
58
+ (async () => {
59
+ try {
60
+ const res = await apiFetch('/api/config/model');
61
+ if (res.ok) {
62
+ const data = await res.json();
63
+ setCurrentModel(data.current);
64
+ setAvailableModels(data.available);
65
+ }
66
+ } catch { /* ignore */ }
67
+ })();
68
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
69
+
70
+ const handleModelChange = useCallback(async (modelId: string) => {
71
+ try {
72
+ const res = await apiFetch('/api/config/model', {
73
+ method: 'POST',
74
+ body: JSON.stringify({ model: modelId }),
75
+ });
76
+ if (res.ok) {
77
+ setCurrentModel(modelId);
78
+ logger.log('Model changed to', modelId);
79
+ }
80
+ } catch { /* ignore */ }
81
+ }, []);
82
+
83
  const isResizing = useRef(false);
84
 
85
  const handleMouseMove = useCallback((e: MouseEvent) => {
 
334
  </Box>
335
 
336
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
337
+ {/* Model selector */}
338
+ {availableModels.length > 0 && (
339
+ <Select
340
+ value={currentModel}
341
+ onChange={(e) => handleModelChange(e.target.value)}
342
+ size="small"
343
+ variant="outlined"
344
+ sx={{
345
+ fontSize: '0.72rem',
346
+ height: 30,
347
+ color: 'var(--muted-text)',
348
+ '& .MuiOutlinedInput-notchedOutline': {
349
+ borderColor: 'var(--border)',
350
+ },
351
+ '&:hover .MuiOutlinedInput-notchedOutline': {
352
+ borderColor: 'var(--border-hover)',
353
+ },
354
+ '& .MuiSelect-select': {
355
+ py: 0.5,
356
+ px: 1,
357
+ },
358
+ }}
359
+ >
360
+ {availableModels.map((m) => (
361
+ <MenuItem key={m.id} value={m.id} sx={{ fontSize: '0.75rem' }}>
362
+ {m.label}
363
+ </MenuItem>
364
+ ))}
365
+ </Select>
366
+ )}
367
  <IconButton
368
  onClick={toggleTheme}
369
  size="small"