personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add retry button for failed chat messages and disable continuation on error

When a chat thread ends in error, users can no longer continue the
conversation (which made no semantic sense). Instead, a retry button
appears inline after the error message to re-attempt the failed request.

Backend changes:
- Add get_agent_end_state() to detect finish vs error terminal states
- Add /api/chat/<id>/retry endpoint to re-send failed prompts
- Return can_continue and end_state in chat events response
- Extract API key validation to _get_backend_api_key() helper (DRY)

Frontend changes:
- Add showRetryButton()/hideRetryButton() and retryChat() functions
- Disable input and show retry button on error (live and historical)
- Add enableInput parameter to showError() to prevent state conflicts
- Add retry button CSS with hover/active states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+379 -23
+162 -10
apps/chat/routes.py
··· 100 100 ) 101 101 102 102 103 + def _get_backend_api_key(backend: str) -> str | None: 104 + """Get the API key name for a backend and check if it's set. 105 + 106 + Args: 107 + backend: The backend name (openai, anthropic, google) 108 + 109 + Returns: 110 + The API key value if set, None otherwise 111 + """ 112 + key_names = { 113 + "openai": "OPENAI_API_KEY", 114 + "anthropic": "ANTHROPIC_API_KEY", 115 + } 116 + key_name = key_names.get(backend, "GOOGLE_API_KEY") 117 + return os.getenv(key_name) 118 + 119 + 120 + def _get_backend_key_name(backend: str) -> str: 121 + """Get the environment variable name for a backend's API key. 122 + 123 + Args: 124 + backend: The backend name (openai, anthropic, google) 125 + 126 + Returns: 127 + The environment variable name 128 + """ 129 + key_names = { 130 + "openai": "OPENAI_API_KEY", 131 + "anthropic": "ANTHROPIC_API_KEY", 132 + } 133 + return key_names.get(backend, "GOOGLE_API_KEY") 134 + 135 + 103 136 def generate_chat_title(message: str) -> str: 104 137 """Generate a short title for a chat message using Gemini Flash Lite.""" 105 138 try: ··· 144 177 except FileNotFoundError: 145 178 pass # Chat exists but agent file missing - treat as new 146 179 147 - if backend == "openai": 148 - key_name = "OPENAI_API_KEY" 149 - elif backend == "anthropic": 150 - key_name = "ANTHROPIC_API_KEY" 151 - else: 152 - key_name = "GOOGLE_API_KEY" 153 - 154 - if not os.getenv(key_name): 180 + if not _get_backend_api_key(backend): 181 + key_name = _get_backend_key_name(backend) 155 182 resp = jsonify({"error": f"{key_name} not set"}) 156 183 resp.status_code = 500 157 184 return resp ··· 227 254 Derives thread from agent files, then hydrates events from all agents. 228 255 For active chats, client should subscribe to WebSocket for real-time updates. 229 256 """ 230 - from muse.cortex_client import get_agent_status, get_agent_thread, read_agent_events 257 + from muse.cortex_client import ( 258 + get_agent_end_state, 259 + get_agent_status, 260 + get_agent_thread, 261 + read_agent_events, 262 + ) 231 263 232 264 chat = _load_chat(chat_id) 233 265 if not chat: ··· 251 283 # Agent file might not exist yet for very new agents 252 284 pass 253 285 254 - # Check if the last agent in the thread is complete 286 + # Check if the last agent in the thread is complete and how it ended 255 287 is_complete = False 288 + end_state = None 289 + can_continue = False 256 290 if thread: 257 291 is_complete = get_agent_status(thread[-1]) == "completed" 292 + if is_complete: 293 + end_state = get_agent_end_state(thread[-1]) 294 + can_continue = end_state == "finish" 258 295 259 296 # Find the backend used by the last agent in the thread 260 297 last_backend = None ··· 270 307 chat=chat, 271 308 thread=thread, 272 309 is_complete=is_complete, 310 + end_state=end_state, 311 + can_continue=can_continue, 273 312 last_backend=last_backend, 274 313 ) 275 314 ··· 352 391 save_json(chat_file, chat_data) 353 392 354 393 return jsonify({"success": True}) 394 + 395 + except Exception as e: 396 + resp = jsonify({"error": str(e)}) 397 + resp.status_code = 500 398 + return resp 399 + 400 + 401 + @chat_bp.route("/api/chat/<chat_id>/retry", methods=["POST"]) 402 + def retry_chat(chat_id: str) -> Any: 403 + """Retry the last failed message in a chat. 404 + 405 + Reads the last agent's prompt and spawns a new agent with the same prompt, 406 + continuing from the errored agent. Uses the backend specified in the request. 407 + 408 + Args: 409 + chat_id: The chat ID 410 + 411 + Returns: 412 + JSON with agent_id of the new retry attempt 413 + """ 414 + from muse.cortex_client import ( 415 + get_agent_end_state, 416 + get_agent_thread, 417 + read_agent_events, 418 + ) 419 + 420 + payload = request.get_json(force=True) if request.data else {} 421 + backend = payload.get("backend") 422 + 423 + if not backend: 424 + resp = jsonify({"error": "backend is required"}) 425 + resp.status_code = 400 426 + return resp 427 + 428 + # Validate chat exists 429 + chats_dir = get_app_storage_path("chat", "chats", ensure_exists=False) 430 + if not (chats_dir / f"{chat_id}.json").exists(): 431 + resp = jsonify({"error": f"Chat not found: {chat_id}"}) 432 + resp.status_code = 404 433 + return resp 434 + 435 + # Get thread and verify last agent ended in error 436 + try: 437 + thread = get_agent_thread(chat_id) 438 + except FileNotFoundError: 439 + resp = jsonify({"error": "Chat thread not found"}) 440 + resp.status_code = 404 441 + return resp 442 + 443 + if not thread: 444 + resp = jsonify({"error": "Chat thread is empty"}) 445 + resp.status_code = 404 446 + return resp 447 + 448 + last_agent_id = thread[-1] 449 + end_state = get_agent_end_state(last_agent_id) 450 + 451 + if end_state != "error": 452 + resp = jsonify({"error": f"Cannot retry: last agent ended with '{end_state}'"}) 453 + resp.status_code = 400 454 + return resp 455 + 456 + # Extract prompt from last agent's start event 457 + try: 458 + events = read_agent_events(last_agent_id) 459 + except FileNotFoundError: 460 + resp = jsonify({"error": "Could not read agent events"}) 461 + resp.status_code = 500 462 + return resp 463 + 464 + prompt = None 465 + for event in events: 466 + if event.get("event") == "start": 467 + prompt = event.get("prompt") 468 + break 469 + 470 + if not prompt: 471 + resp = jsonify({"error": "Could not find original prompt to retry"}) 472 + resp.status_code = 500 473 + return resp 474 + 475 + # Validate API key 476 + if not _get_backend_api_key(backend): 477 + key_name = _get_backend_key_name(backend) 478 + resp = jsonify({"error": f"{key_name} not set"}) 479 + resp.status_code = 500 480 + return resp 481 + 482 + try: 483 + from convey.utils import spawn_agent 484 + 485 + # Get facet from chat metadata for context 486 + chat_data = load_json(chats_dir / f"{chat_id}.json") 487 + facet = chat_data.get("facet") if chat_data else None 488 + 489 + config: dict[str, Any] = {"continue_from": last_agent_id} 490 + if facet: 491 + config["facet"] = facet 492 + 493 + # Spawn retry agent 494 + agent_id = spawn_agent( 495 + prompt=prompt, 496 + persona="default", 497 + backend=backend, 498 + config=config, 499 + ) 500 + 501 + # Update chat timestamp 502 + if chat_data: 503 + chat_data["updated_ts"] = int(time.time() * 1000) 504 + save_json(chats_dir / f"{chat_id}.json", chat_data) 505 + 506 + return jsonify(agent_id=agent_id) 355 507 356 508 except Exception as e: 357 509 resp = jsonify({"error": str(e)})
+131 -13
apps/chat/workspace.html
··· 206 206 .activity-indicator span:nth-child(1) { animation-delay: -0.32s; } 207 207 .activity-indicator span:nth-child(2) { animation-delay: -0.16s; } 208 208 209 + /* Retry button for failed chats */ 210 + .retry-container { 211 + align-self: flex-start; 212 + margin-bottom: 0.75em; 213 + animation: fadeInUp 0.3s ease-out; 214 + } 215 + .retry-button { 216 + background: #fff3e0; 217 + border: 1px solid #ff9800; 218 + border-radius: 6px; 219 + padding: 0.5em 1em; 220 + font-size: 0.9em; 221 + cursor: pointer; 222 + color: #e65100; 223 + transition: all 0.2s; 224 + display: flex; 225 + align-items: center; 226 + gap: 0.4em; 227 + } 228 + .retry-button:hover { 229 + background: #ffe0b2; 230 + border-color: #f57c00; 231 + transform: translateY(-1px); 232 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 233 + } 234 + .retry-button:active { 235 + transform: translateY(0); 236 + box-shadow: none; 237 + } 238 + .retry-icon { 239 + font-size: 1.1em; 240 + } 241 + 209 242 /* Recent chats panel */ 210 243 .recent-chats-panel { 211 244 display: flex; ··· 445 478 }); 446 479 447 480 // Simple error display - adds error message to chat 448 - function showError(message) { 481 + function showError(message, enableInput = true) { 449 482 const div = document.createElement('div'); 450 483 div.className = 'message from-bot'; 451 484 div.innerHTML = `<strong style="color:#c62828">Error:</strong> ${message}`; 452 485 messagesDiv.appendChild(div); 453 486 messagesDiv.scrollTop = messagesDiv.scrollHeight; 454 487 455 - // Re-enable input so user can retry 456 - if (input) { 488 + // Re-enable input so user can retry (unless caller wants to handle it) 489 + if (enableInput && input) { 457 490 input.disabled = false; 458 491 input.placeholder = 'Send a message...'; 459 492 input.focus(); ··· 594 627 let currentChatId = null; // The chat being viewed (stable across continuations) 595 628 let currentThread = []; // Agent IDs in the current thread (for WebSocket filtering) 596 629 let isComplete = false; 630 + let canContinue = false; // Whether the chat can be continued (false if ended in error) 597 631 let currentBookmarked = null; // Bookmark timestamp or null 598 632 599 633 // ═══════════════════════════════════════════════════════════════════════════ ··· 689 723 currentChatId = chatId; 690 724 currentThread = []; 691 725 isComplete = false; 726 + canContinue = false; 692 727 resetEventTracking(); 728 + hideRetryButton(); 693 729 694 730 // Notify background service 695 731 if (window.AppServices?.services?.chat) { ··· 712 748 const events = data.events || []; 713 749 currentThread = data.thread || [chatId]; 714 750 isComplete = data.is_complete || false; 751 + canContinue = data.can_continue || false; 715 752 716 753 // Update backend toggle to match last used backend in this chat 717 754 if (data.last_backend) { ··· 729 766 } 730 767 } 731 768 732 - // Update UI based on completion 769 + // Update UI based on completion state 733 770 if (isComplete) { 734 771 forceHideActivityIndicator(); 735 - enableContinuation(chatId); 772 + if (canContinue) { 773 + enableContinuation(chatId); 774 + } else if (data.end_state === 'error') { 775 + showRetryButton(chatId); 776 + } 736 777 } 737 778 738 779 // Mark as read and update badges ··· 751 792 * @param {string} chatId - The chat ID to continue 752 793 */ 753 794 function enableContinuation(chatId) { 795 + hideRetryButton(); 754 796 if (input) { 755 797 input.disabled = false; 756 798 input.placeholder = 'Continue conversation...'; ··· 759 801 } 760 802 761 803 /** 804 + * Show retry button after an error. Adds button inline after the last message. 805 + * @param {string} chatId - The chat ID to retry 806 + */ 807 + function showRetryButton(chatId) { 808 + // Remove any existing retry button 809 + hideRetryButton(); 810 + 811 + // Create retry button container 812 + const retryDiv = document.createElement('div'); 813 + retryDiv.id = 'retry-container'; 814 + retryDiv.className = 'retry-container'; 815 + retryDiv.innerHTML = ` 816 + <button class="retry-button" onclick="retryChat('${chatId}')"> 817 + <span class="retry-icon">↻</span> Retry 818 + </button> 819 + `; 820 + messagesDiv.appendChild(retryDiv); 821 + messagesDiv.scrollTop = messagesDiv.scrollHeight; 822 + 823 + // Keep input disabled 824 + if (input) { 825 + input.disabled = true; 826 + input.placeholder = 'Retry failed message...'; 827 + delete input.dataset.continueChat; 828 + } 829 + } 830 + 831 + /** 832 + * Hide and remove the retry button. 833 + */ 834 + function hideRetryButton() { 835 + const existing = document.getElementById('retry-container'); 836 + if (existing) { 837 + existing.remove(); 838 + } 839 + } 840 + 841 + /** 842 + * Retry the last failed message in the current chat. 843 + * @param {string} chatId - The chat ID to retry 844 + */ 845 + async function retryChat(chatId) { 846 + hideRetryButton(); 847 + showActivityIndicator(); 848 + canContinue = false; 849 + 850 + try { 851 + const response = await fetch(`/app/chat/api/chat/${chatId}/retry`, { 852 + method: 'POST', 853 + headers: { 'Content-Type': 'application/json' }, 854 + body: JSON.stringify({ backend: currentBackend }) 855 + }); 856 + 857 + if (!response.ok) { 858 + const error = await response.json(); 859 + throw new Error(error.error || 'Retry failed'); 860 + } 861 + 862 + const data = await response.json(); 863 + // Add new agent to thread for WebSocket event filtering 864 + currentThread.push(data.agent_id); 865 + isComplete = false; 866 + // Events will arrive via WebSocket 867 + 868 + } catch (error) { 869 + forceHideActivityIndicator(); 870 + showError(`Retry failed: ${error.message}`, false); 871 + // Re-show retry button on failure (this will disable input) 872 + showRetryButton(chatId); 873 + } 874 + } 875 + 876 + /** 762 877 * Mark chat as read and update all badge/submenu state. 763 878 * @param {string} chatId - The chat to mark read 764 879 */ ··· 790 905 currentChatId = null; 791 906 currentThread = []; 792 907 isComplete = false; 908 + canContinue = false; 793 909 clearMessages(); 910 + hideRetryButton(); 794 911 updateStarButton(null); 795 912 796 913 if (input) { ··· 1197 1314 currentChatId = null; 1198 1315 currentThread = []; 1199 1316 isComplete = false; 1317 + canContinue = false; 1200 1318 forceHideActivityIndicator(); 1201 1319 resetEventTracking(); 1202 1320 ··· 1256 1374 // Don't reload - that clears UI and causes race conditions with WebSocket events 1257 1375 currentThread.push(d.agent_id); 1258 1376 isComplete = false; 1377 + canContinue = false; 1259 1378 showActivityIndicator(); 1260 1379 } else { 1261 1380 // New chat: navigate to it ··· 1300 1419 } 1301 1420 1302 1421 isComplete = true; 1422 + canContinue = true; 1303 1423 forceHideActivityIndicator(); 1304 1424 1305 1425 // Re-enable input and enable continuation using current chat ID 1306 - if(input && currentChatId){ 1307 - input.disabled = false; 1308 - input.placeholder = 'Continue conversation...'; 1309 - input.dataset.continueChat = currentChatId; 1426 + if(currentChatId){ 1427 + enableContinuation(currentChatId); 1310 1428 } 1311 1429 break; 1312 1430 ··· 1317 1435 } 1318 1436 1319 1437 isComplete = true; 1438 + canContinue = false; 1320 1439 forceHideActivityIndicator(); 1321 1440 1322 - // Re-enable input so user can retry 1323 - if(input){ 1324 - input.disabled = false; 1325 - input.placeholder = 'Send a message...'; 1441 + // Show retry button instead of enabling input 1442 + if(currentChatId){ 1443 + showRetryButton(currentChatId); 1326 1444 } 1327 1445 break; 1328 1446 }
+33
muse/cortex_client.py
··· 160 160 return "not_found" 161 161 162 162 163 + def get_agent_end_state(agent_id: str) -> str: 164 + """Get how a completed agent ended (finish or error). 165 + 166 + Args: 167 + agent_id: The agent ID (timestamp) 168 + 169 + Returns: 170 + "finish" - Agent completed successfully 171 + "error" - Agent ended with an error 172 + "running" - Agent is still active 173 + "unknown" - Agent file exists but no terminal event found 174 + """ 175 + status = get_agent_status(agent_id) 176 + if status == "running": 177 + return "running" 178 + if status == "not_found": 179 + return "unknown" 180 + 181 + # Read events to find terminal state 182 + try: 183 + events = read_agent_events(agent_id) 184 + # Find last finish or error event 185 + for event in reversed(events): 186 + event_type = event.get("event") 187 + if event_type == "finish": 188 + return "finish" 189 + if event_type == "error": 190 + return "error" 191 + return "unknown" 192 + except FileNotFoundError: 193 + return "unknown" 194 + 195 + 163 196 def read_agent_events(agent_id: str) -> list[Dict[str, Any]]: 164 197 """Read all events from an agent's JSONL log file. 165 198
+53
tests/test_cortex_client.py
··· 16 16 from muse.cortex_client import ( 17 17 cortex_agents, 18 18 cortex_request, 19 + get_agent_end_state, 19 20 get_agent_status, 20 21 get_agent_thread, 21 22 ) ··· 347 348 (agents_dir / f"{agent_id}_active.jsonl").write_text('{"event": "start"}\n') 348 349 349 350 assert get_agent_status(agent_id) == "completed" 351 + 352 + 353 + def test_get_agent_end_state_finish(tmp_path, monkeypatch): 354 + """Test get_agent_end_state returns 'finish' for successful agents.""" 355 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 356 + agents_dir = tmp_path / "agents" 357 + agents_dir.mkdir() 358 + 359 + agent_id = "1234567890123" 360 + (agents_dir / f"{agent_id}.jsonl").write_text( 361 + '{"event": "request", "prompt": "hello"}\n' 362 + '{"event": "finish", "result": "done"}\n' 363 + ) 364 + 365 + assert get_agent_end_state(agent_id) == "finish" 366 + 367 + 368 + def test_get_agent_end_state_error(tmp_path, monkeypatch): 369 + """Test get_agent_end_state returns 'error' for failed agents.""" 370 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 371 + agents_dir = tmp_path / "agents" 372 + agents_dir.mkdir() 373 + 374 + agent_id = "1234567890123" 375 + (agents_dir / f"{agent_id}.jsonl").write_text( 376 + '{"event": "request", "prompt": "hello"}\n' 377 + '{"event": "error", "error": "something went wrong"}\n' 378 + ) 379 + 380 + assert get_agent_end_state(agent_id) == "error" 381 + 382 + 383 + def test_get_agent_end_state_running(tmp_path, monkeypatch): 384 + """Test get_agent_end_state returns 'running' for active agents.""" 385 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 386 + agents_dir = tmp_path / "agents" 387 + agents_dir.mkdir() 388 + 389 + agent_id = "1234567890123" 390 + (agents_dir / f"{agent_id}_active.jsonl").write_text( 391 + '{"event": "request", "prompt": "hello"}\n' 392 + ) 393 + 394 + assert get_agent_end_state(agent_id) == "running" 395 + 396 + 397 + def test_get_agent_end_state_unknown(tmp_path, monkeypatch): 398 + """Test get_agent_end_state returns 'unknown' for missing agents.""" 399 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 400 + (tmp_path / "agents").mkdir() 401 + 402 + assert get_agent_end_state("nonexistent") == "unknown" 350 403 351 404 352 405 def test_get_agent_thread_single_agent(tmp_path, monkeypatch):