From 73e404c295e0621b54ed0cf0cb2c034477d351fb Mon Sep 17 00:00:00 2001 From: Korivi Date: Fri, 10 Apr 2026 01:43:38 +0900 Subject: [PATCH 1/6] Fixed the service. Uninstall it now. Clean the Task Scheduler and startup task. --- service.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/service.py b/service.py index 725986b8..37689924 100644 --- a/service.py +++ b/service.py @@ -547,6 +547,9 @@ def _install_windows(run_args: List[str]) -> None: def _uninstall_windows() -> None: + removed_any = False + + # Remove from Task Scheduler try: result = subprocess.run( ["schtasks", "/delete", "/tn", TASK_NAME, "/f"], @@ -554,11 +557,31 @@ def _uninstall_windows() -> None: ) if result.returncode == 0: print(f"Auto-start removed (task '{TASK_NAME}' deleted).") - else: - # Task may not exist - print(f"Could not remove task (it may not be registered): {result.stderr.strip()}") + removed_any = True + except Exception as e: + print(f"Warning: Could not query Task Scheduler — {e}") + + # Remove from Registry (HKCU\...\Run) — the fallback auto-start method + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + 0, winreg.KEY_SET_VALUE, + ) + try: + winreg.DeleteValue(key, TASK_NAME) + print(f"Auto-start removed (registry entry '{TASK_NAME}' deleted).") + removed_any = True + except FileNotFoundError: + pass # Entry didn't exist in registry — that's fine + finally: + winreg.CloseKey(key) except Exception as e: - print(f"Error: {e}") + print(f"Warning: Could not clean registry — {e}") + + if not removed_any: + print("No auto-start registration found (already uninstalled?).") # ─── Auto-start: Linux systemd (user service) ───────────────────────────────── @@ -789,6 +812,23 @@ def cmd_install(extra_args: List[str]) -> None: _close_console_window() +def _remove_desktop_shortcut() -> None: + """Remove the CraftBot desktop shortcut if it exists.""" + desktop = _find_desktop() + if not desktop: + return + if _PLATFORM == "win32": + shortcut_path = os.path.join(desktop, SHORTCUT_NAME) + else: + shortcut_path = os.path.join(desktop, "CraftBot.desktop") + if os.path.isfile(shortcut_path): + try: + os.remove(shortcut_path) + print(f"Desktop shortcut removed: {shortcut_path}") + except Exception as e: + print(f"Warning: Could not remove desktop shortcut — {e}") + + def cmd_uninstall() -> None: """Remove auto-start registration and uninstall dependencies.""" # Stop the service first if running @@ -796,6 +836,9 @@ def cmd_uninstall() -> None: if pid and _is_running(pid): cmd_stop() + # Clean up PID file + _remove_pid() + # Remove auto-start registration plat = _PLATFORM if plat == "win32": @@ -805,6 +848,9 @@ def cmd_uninstall() -> None: else: _uninstall_linux() + # Remove desktop shortcut + _remove_desktop_shortcut() + # Uninstall pip packages req_file = os.path.join(BASE_DIR, "requirements.txt") if os.path.isfile(req_file): From 894b73286c62a3e40b0d3c431cc691d334d32500 Mon Sep 17 00:00:00 2001 From: korivi-CraftOS Date: Tue, 14 Apr 2026 17:28:50 +0900 Subject: [PATCH 2/6] Delete craftbot.log --- craftbot.log | 299 --------------------------------------------------- 1 file changed, 299 deletions(-) delete mode 100644 craftbot.log diff --git a/craftbot.log b/craftbot.log deleted file mode 100644 index fe1ee0ea..00000000 --- a/craftbot.log +++ /dev/null @@ -1,299 +0,0 @@ - -============================================================ -CraftBot service started at 2026-04-08 14:51:22 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ -Traceback (most recent call last): - File "C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py", line 1074, in - print_browser_header() - ~~~~~~~~~~~~~~~~~~~~^^ - File "C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py", line 610, in print_browser_header - print("\n\U0001f916 CraftBot") - ~~~~~^^^^^^^^^^^^^^^^^ - File "C:\Python314\Lib\encodings\cp1252.py", line 19, in encode - return codecs.charmap_encode(input,self.errors,encoding_table)[0] - ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f916' in position 2: character maps to - -============================================================ -CraftBot service started at 2026-04-08 14:59:15 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ -Traceback (most recent call last): - File "C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py", line 1074, in - print_browser_header() - ~~~~~~~~~~~~~~~~~~~~^^ - File "C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py", line 610, in print_browser_header - print("\n\U0001f916 CraftBot") - ~~~~~^^^^^^^^^^^^^^^^^ - File "C:\Python314\Lib\encodings\cp1252.py", line 19, in encode - return codecs.charmap_encode(input,self.errors,encoding_table)[0] - ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f916' in position 2: character maps to - -============================================================ -CraftBot service started at 2026-04-08 15:07:33 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ -Traceback (most recent call last): - File "C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py", line 1074, in - print_browser_header() - ~~~~~~~~~~~~~~~~~~~~^^ - File "C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py", line 610, in print_browser_header - print("\n\U0001f916 CraftBot") - ~~~~~^^^^^^^^^^^^^^^^^ - File "C:\Python314\Lib\encodings\cp1252.py", line 19, in encode - return codecs.charmap_encode(input,self.errors,encoding_table)[0] - ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f916' in position 2: character maps to - -============================================================ -CraftBot service started at 2026-04-08 15:18:54 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - - ---- Cleanup Initiated (Exit Status: 1073807364) --- -[*] Skipping Docker cleanup (not started in CLI mode). - -============================================================ -CraftBot service started at 2026-04-08 16:27:25 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 16:51:37 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 17:18:37 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 17:38:37 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 17:52:20 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 17:59:29 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 18:05:16 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 18:16:07 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 20:52:14 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 20:57:52 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-08 21:06:10 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ - -============================================================ -CraftBot service started at 2026-04-09 00:58:46 -Command: C:\Python314\pythonw.exe C:\Users\ganiy\OneDrive\Desktop\OneDrive\Korivi Important Data\Aether\CraftOS\CraftBot\CraftBot\run.py --no-open-browser -============================================================ - -🤖 CraftBot -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Mode: Browser - - [ 1/8] Starting frontend server... ✓ - [ 2/8] Starting agent backend... ✓ - [ 3/8] Initializing agent... ✓ - [ 4/8] Connecting to MCP servers... ✓ - [ 5/8] Loading skills... ✓ - [ 6/8] Loading libraries... ✓ - [ 7/8] Starting scheduler... ✓ - [ 8/8] Starting communications... ✓ From fb898a8e406324f603c4e3d083cc054c0fdb6d23 Mon Sep 17 00:00:00 2001 From: korivi-CraftOS Date: Tue, 14 Apr 2026 17:29:07 +0900 Subject: [PATCH 3/6] Delete craftbot.pid --- craftbot.pid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 craftbot.pid diff --git a/craftbot.pid b/craftbot.pid deleted file mode 100644 index b86a3065..00000000 --- a/craftbot.pid +++ /dev/null @@ -1 +0,0 @@ -10948 \ No newline at end of file From 4fea2d681f3e31e2550fdcada09ef71faa19be91 Mon Sep 17 00:00:00 2001 From: zfoong Date: Wed, 15 Apr 2026 15:02:48 +0900 Subject: [PATCH 4/6] feature:token limit handling with interface update --- agent_core/core/state/types.py | 2 +- app/agent_base.py | 176 ++++++++++++++++-- app/gui/gui_module.py | 6 +- app/ui_layer/adapters/base.py | 18 +- app/ui_layer/adapters/browser_adapter.py | 64 +++++++ .../src/contexts/WebSocketContext.tsx | 9 + .../frontend/src/pages/Chat/ChatMessage.tsx | 25 +++ .../src/pages/Chat/ChatPage.module.css | 33 ++++ .../frontend/src/pages/Chat/ChatPage.tsx | 3 +- .../browser/frontend/src/types/index.ts | 10 + app/ui_layer/components/types.py | 18 ++ app/ui_layer/controller/ui_controller.py | 15 ++ app/usage/chat_storage.py | 58 +++++- 13 files changed, 414 insertions(+), 23 deletions(-) diff --git a/agent_core/core/state/types.py b/agent_core/core/state/types.py index c4a95edd..6c069b4f 100644 --- a/agent_core/core/state/types.py +++ b/agent_core/core/state/types.py @@ -11,7 +11,7 @@ import logging # Default configuration values - can be overridden at runtime -DEFAULT_MAX_ACTIONS_PER_TASK = 150 +DEFAULT_MAX_ACTIONS_PER_TASK = 10 DEFAULT_MAX_TOKEN_PER_TASK = 6_000_000 # Use standard logging since loguru may not be available during import diff --git a/app/agent_base.py b/app/agent_base.py index 8ee53288..d629741e 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -1268,18 +1268,17 @@ async def _check_agent_limits(self) -> bool: # Check action limits if (action_count / max_actions) >= 1.0: - # Log warning BEFORE cancelling task (stream is removed during cancel) if self.event_stream_manager: self.event_stream_manager.log( "warning", - f"Action limit reached: 100% of the maximum actions ({max_actions} actions) has been used. Aborting task.", - display_message=f"Action limit reached: 100% of the maximum ({max_actions} actions) has been used. Aborting task.", + f"Action limit reached: 100% of the maximum actions ({max_actions} actions) has been used. Waiting for user decision.", + display_message=None, task_id=current_task_id, ) self.state_manager.bump_event_stream() - response = await self.task_manager.mark_task_cancel(reason=f"Task reached the maximum actions allowed limit: {max_actions}") - task_cancelled: bool = response - return not task_cancelled + await self._send_limit_choice_message("action", action_count, max_actions, current_task_id) + await self._pause_task_for_limit_choice(current_task_id) + return False elif (action_count / max_actions) >= 0.8: if self.event_stream_manager: self.event_stream_manager.log( @@ -1295,18 +1294,17 @@ async def _check_agent_limits(self) -> bool: # Check token limits if (token_count / max_tokens) >= 1.0: - # Log warning BEFORE cancelling task (stream is removed during cancel) if self.event_stream_manager: self.event_stream_manager.log( "warning", - f"Token limit reached: 100% of the maximum tokens ({max_tokens} tokens) has been used. Aborting task.", - display_message=f"Token limit reached: 100% of the maximum ({max_tokens} tokens) has been used. Aborting task.", + f"Token limit reached: 100% of the maximum tokens ({max_tokens} tokens) has been used. Waiting for user decision.", + display_message=None, task_id=current_task_id, ) self.state_manager.bump_event_stream() - response = await self.task_manager.mark_task_cancel(reason=f"Task reached the maximum tokens allowed limit: {max_tokens}") - task_cancelled: bool = response - return not task_cancelled + await self._send_limit_choice_message("token", token_count, max_tokens, current_task_id) + await self._pause_task_for_limit_choice(current_task_id) + return False elif (token_count / max_tokens) >= 0.8: if self.event_stream_manager: self.event_stream_manager.log( @@ -1323,6 +1321,160 @@ async def _check_agent_limits(self) -> bool: # No limits close or reached return True + async def _send_limit_choice_message( + self, limit_type: str, current: int, maximum: int, session_id: str + ) -> None: + """Send a chat message with Continue/Abort options when a limit is reached.""" + label = "Action" if limit_type == "action" else "Token" + unit = "actions" if limit_type == "action" else "tokens" + message = ( + f"{label} limit reached: {current}/{maximum} {unit} used. " + f"Would you like to continue (reset limits) or abort the task?" + ) + logger.info(f"[LIMIT] Sending limit choice message for session {session_id}: {message}") + + # Log to event stream for task context persistence only (display_message=None + # to avoid a duplicate chat message from the event watcher). + if self.event_stream_manager: + try: + self.event_stream_manager.log( + "internal", + message, + display_message=None, + task_id=session_id, + ) + except Exception as e: + logger.error(f"[LIMIT] Failed to log to event stream: {e}", exc_info=True) + + # Display message with options directly in the chat UI (awaited). + # We bypass the event bus (which uses fire-and-forget create_task) + # to ensure the message is broadcast before the method returns. + if self.ui_controller and self.ui_controller.active_adapter: + try: + from app.ui_layer.components.types import ChatMessage, ChatMessageOption + from app.onboarding import onboarding_manager + import time as _time + agent_name = onboarding_manager.state.agent_name or "Agent" + options = [ + ChatMessageOption(label="Continue", value="continue_limit", style="primary"), + ChatMessageOption(label="Abort", value="abort_limit", style="danger"), + ] + await self.ui_controller.active_adapter.chat_component.append_message( + ChatMessage( + sender=agent_name, + content=message, + style="agent", + timestamp=_time.time(), + task_session_id=session_id, + options=options, + ) + ) + logger.info(f"[LIMIT] Options message displayed in chat for session {session_id}") + except Exception as e: + logger.error(f"[LIMIT] Failed to display options in chat: {e}", exc_info=True) + else: + logger.warning(f"[LIMIT] No active UI adapter - options message not displayed") + + async def _pause_task_for_limit_choice(self, session_id: str) -> None: + """Pause the task and create a long-delay trigger to keep it alive.""" + logger.info(f"[LIMIT] Pausing task {session_id} for limit choice") + task = self.task_manager.tasks.get(session_id) if self.task_manager else None + if task: + task.waiting_for_user_reply = True + + # Update UI state to "waiting" + if self.ui_controller: + from app.ui_layer.events import UIEvent, UIEventType + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.TASK_UPDATE, + data={"task_id": session_id, "status": "waiting"}, + ) + ) + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={"state": "waiting", "status_message": "Waiting for user decision..."}, + ) + ) + + # Create a long-delay trigger so the task stays alive + try: + await self.triggers.put( + Trigger( + fire_at=time.time() + 10800, + priority=5, + next_action_description="Waiting for user decision on limit reached", + session_id=session_id, + payload={"gui_mode": STATE.gui_mode}, + waiting_for_reply=True, + ), + skip_merge=True, + ) + except Exception as e: + logger.error(f"[LIMIT] Failed to create pause trigger for {session_id}: {e}", exc_info=True) + + async def handle_limit_continue(self, session_id: str) -> None: + """User chose to continue past the limit. Reset counters and resume.""" + task = self.task_manager.tasks.get(session_id) if self.task_manager else None + if not task: + logger.warning(f"[LIMIT] Task {session_id} not found for limit continue") + return + + # Reset counters + STATE.set_agent_property("action_count", 0) + STATE.set_agent_property("token_count", 0) + + # Also reset on the StateSession for this session + from agent_core.core.state.session import StateSession + session = StateSession.get(session_id) + if session: + session.agent_properties.set("action_count", 0) + session.agent_properties.set("token_count", 0) + + # Clear waiting flag + task.waiting_for_user_reply = False + + # Log to event stream + if self.event_stream_manager: + self.event_stream_manager.log( + "info", + "User chose to continue. Action and token counters have been reset.", + display_message=None, + task_id=session_id, + ) + self.state_manager.bump_event_stream() + + # Update UI state back to working + if self.ui_controller: + from app.ui_layer.events import UIEvent, UIEventType + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.TASK_UPDATE, + data={"task_id": session_id, "status": "running"}, + ) + ) + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={"state": "working", "status_message": "Agent is working..."}, + ) + ) + + # Fire the trigger to resume execution + await self.triggers.fire(session_id) + + async def handle_limit_abort(self, session_id: str) -> None: + """User chose to abort after reaching limit.""" + task = self.task_manager.tasks.get(session_id) if self.task_manager else None + if task: + task.waiting_for_user_reply = False + if self.task_manager: + await self.task_manager.mark_task_cancel( + reason="User chose to abort after reaching limit.", + task_id=session_id, + ) + # ----- Trigger Management ----- async def _cleanup_session_triggers(self, session_id: str) -> None: diff --git a/app/gui/gui_module.py b/app/gui/gui_module.py index 6d53c583..24374600 100644 --- a/app/gui/gui_module.py +++ b/app/gui/gui_module.py @@ -786,13 +786,15 @@ async def _check_agent_limits(self) -> bool: token_count: int = agent_properties.get("token_count", 0) max_tokens: int = agent_properties.get("max_tokens_per_task", 0) - # Check action limits + # Check action limits - returns False to switch to CLI mode, + # where the agent_base's _check_agent_limits will handle the + # pause-and-ask flow with user options. if (action_count / max_actions) >= 1.0: return False # Check token limits if (token_count / max_tokens) >= 1.0: return False - + # No limits close or reached return True \ No newline at end of file diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index f8f9aa8f..13dfdefc 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -15,7 +15,7 @@ InputComponentProtocol, FootageComponentProtocol, ) -from app.ui_layer.components.types import ChatMessage, ActionItem +from app.ui_layer.components.types import ChatMessage, ChatMessageOption, ActionItem if TYPE_CHECKING: from app.ui_layer.controller.ui_controller import UIController @@ -271,12 +271,25 @@ def _handle_agent_message(self, event: UIEvent) -> None: from app.onboarding import onboarding_manager agent_name = onboarding_manager.state.agent_name or "Agent" + # Extract options from event data if present + raw_options = event.data.get("options") + options = None + if raw_options and isinstance(raw_options, list): + options = [ + ChatMessageOption( + label=o.get("label", ""), + value=o.get("value", ""), + style=o.get("style", "default"), + ) + for o in raw_options + ] asyncio.create_task( self._display_chat_message( agent_name, event.data.get("message", ""), "agent", task_session_id=event.task_id, + options=options, ) ) @@ -442,6 +455,7 @@ async def _display_chat_message( message: str, style: str, task_session_id: Optional[str] = None, + options: Optional[List[ChatMessageOption]] = None, ) -> None: """ Display a chat message. @@ -451,6 +465,7 @@ async def _display_chat_message( message: Message content style: Style identifier task_session_id: Optional task session ID for reply feature + options: Optional list of interactive options/buttons """ import time @@ -461,6 +476,7 @@ async def _display_chat_message( style=style, timestamp=time.time(), task_session_id=task_session_id, + options=options, ) ) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 0f341056..879a8930 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -200,6 +200,17 @@ def _init_storage(self) -> None: ) for att in stored.attachments ] + options = None + if stored.options: + from app.ui_layer.components.types import ChatMessageOption + options = [ + ChatMessageOption( + label=o.get("label", ""), + value=o.get("value", ""), + style=o.get("style", "default"), + ) + for o in stored.options + ] self._messages.append(ChatMessage( sender=stored.sender, content=stored.content, @@ -208,6 +219,7 @@ def _init_storage(self) -> None: message_id=stored.message_id, attachments=attachments, task_session_id=stored.task_session_id, + options=options, )) except Exception: # Storage may not be available, continue without persistence @@ -233,6 +245,12 @@ async def append_message(self, message: ChatMessage) -> None: } for att in message.attachments ] + options_data = None + if message.options: + options_data = [ + {"label": o.label, "value": o.value, "style": o.style} + for o in message.options + ] stored = StoredChatMessage( message_id=message.message_id or f"{message.sender}:{message.timestamp}", sender=message.sender, @@ -241,6 +259,7 @@ async def append_message(self, message: ChatMessage) -> None: timestamp=message.timestamp, attachments=attachments_data, task_session_id=message.task_session_id, + options=options_data, ) self._storage.insert_message(stored) except Exception: @@ -272,6 +291,13 @@ async def append_message(self, message: ChatMessage) -> None: if message.task_session_id: message_data["taskSessionId"] = message.task_session_id + # Include options/buttons if present + if message.options: + message_data["options"] = [ + {"label": o.label, "value": o.value, "style": o.style} + for o in message.options + ] + await self._adapter._broadcast({ "type": "chat_message", "data": message_data, @@ -320,6 +346,17 @@ def get_messages_before(self, before_timestamp: float, limit: int = 50) -> List[ ) for att in s.attachments ] + options = None + if s.options: + from app.ui_layer.components.types import ChatMessageOption + options = [ + ChatMessageOption( + label=o.get("label", ""), + value=o.get("value", ""), + style=o.get("style", "default"), + ) + for o in s.options + ] messages.append(ChatMessage( sender=s.sender, content=s.content, @@ -327,6 +364,7 @@ def get_messages_before(self, before_timestamp: float, limit: int = 50) -> List[ timestamp=s.timestamp, message_id=s.message_id, attachments=attachments, + options=options, )) return messages except Exception: @@ -1124,6 +1162,12 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: task_id = data.get("taskId", "") await self._handle_task_cancel(task_id) + elif msg_type == "option_click": + value = data.get("value", "") + session_id = data.get("sessionId", "") + message_id = data.get("messageId", "") + await self._handle_option_click(value, session_id, message_id) + # Settings operations elif msg_type == "settings_get": await self._handle_settings_get() @@ -1979,6 +2023,21 @@ async def _handle_task_cancel(self, task_id: str) -> None: }, }) + async def _handle_option_click(self, value: str, session_id: str, message_id: str) -> None: + """Handle a user clicking an option button in a chat message.""" + try: + # Mark the option as selected in storage + if self._chat and self._chat._storage and message_id: + try: + self._chat._storage.update_option_selected(message_id, value) + except Exception: + pass + + # Route to the controller + await self._controller.handle_option_click(value, session_id) + except Exception as e: + logger.error(f"[OPTION_CLICK] Error handling option click: {e}", exc_info=True) + # ───────────────────────────────────────────────────────────────────── # Settings Operation Handlers # ───────────────────────────────────────────────────────────────────── @@ -4311,6 +4370,11 @@ async def _handle_chat_history(self, before_timestamp: float, limit: int = 50) - ] if m.task_session_id: msg_data["taskSessionId"] = m.task_session_id + if m.options: + msg_data["options"] = [ + {"label": o.label, "value": o.value, "style": o.style} + for o in m.options + ] messages_data.append(msg_data) await self._broadcast({ diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index a557c8a1..beb73a2d 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -87,6 +87,8 @@ interface WebSocketContextType extends WebSocketState { startLocalLLM: () => void requestSuggestedModels: () => void pullOllamaModel: (model: string) => void + // Option click (interactive buttons in chat) + sendOptionClick: (value: string, sessionId?: string, messageId?: string) => void } // Initialize lastSeenMessageId from localStorage @@ -770,6 +772,12 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + const sendOptionClick = useCallback((value: string, sessionId?: string, messageId?: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'option_click', value, sessionId, messageId })) + } + }, []) + const openFile = useCallback((path: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'open_file', path })) @@ -943,6 +951,7 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { startLocalLLM, requestSuggestedModels, pullOllamaModel, + sendOptionClick, }} > {children} diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx index 0c5e26c0..dcef691a 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -13,6 +13,7 @@ interface ChatMessageProps { displayName: string, fullContent: string ) => void + onOptionClick?: (value: string, sessionId?: string, messageId?: string) => void } // Parse reply context from message content @@ -33,8 +34,12 @@ export const ChatMessageItem = memo(function ChatMessageItem({ onOpenFile, onOpenFolder, onReply, + onOptionClick, }: ChatMessageProps) { const [isHovered, setIsHovered] = useState(false) + const [optionClicked, setOptionClicked] = useState( + message.optionSelected || null + ) // Show reply for ALL agent messages const canReply = message.style === 'agent' && onReply @@ -95,6 +100,26 @@ export const ChatMessageItem = memo(function ChatMessageItem({ /> )} + {/* Options below the bubble, same level as attachments */} + {message.options && message.options.length > 0 && ( +
+ {message.options.map((opt) => ( + + ))} +
+ )} {message.attachments && message.attachments.length > 0 && (
{ } export function ChatPage() { - const { messages, actions, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder, lastSeenMessageId, markMessagesAsSeen, replyTarget, setReplyTarget, clearReplyTarget, loadOlderMessages, hasMoreMessages, loadingOlderMessages } = useWebSocket() + const { messages, actions, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder, lastSeenMessageId, markMessagesAsSeen, replyTarget, setReplyTarget, clearReplyTarget, loadOlderMessages, hasMoreMessages, loadingOlderMessages, sendOptionClick } = useWebSocket() // Derive agent status from actions and messages const status = useDerivedAgentStatus({ @@ -556,6 +556,7 @@ export function ChatPage() { onOpenFile={openFile} onOpenFolder={openFolder} onReply={handleChatReply} + onOptionClick={sendOptionClick} />
) diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index a6d55b27..bab75e00 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -12,6 +12,12 @@ export interface Attachment { url: string } +export interface ChatMessageOption { + label: string + value: string + style?: 'primary' | 'danger' | 'default' +} + export interface ChatMessage { sender: string content: string @@ -20,6 +26,8 @@ export interface ChatMessage { messageId: string attachments?: Attachment[] taskSessionId?: string // Links message to a task session for reply feature + options?: ChatMessageOption[] + optionSelected?: string // Value of the option that was selected } // ───────────────────────────────────────────────────────────────────── @@ -95,6 +103,8 @@ export type WSMessageType = // Task control | 'task_cancel' | 'task_cancel_response' + // Option click (interactive buttons in chat) + | 'option_click' // Onboarding | 'onboarding_step' | 'onboarding_step_get' diff --git a/app/ui_layer/components/types.py b/app/ui_layer/components/types.py index f0dedf3d..f9206c2d 100644 --- a/app/ui_layer/components/types.py +++ b/app/ui_layer/components/types.py @@ -29,6 +29,22 @@ class Attachment: url: str +@dataclass +class ChatMessageOption: + """ + Data structure for an interactive option/button in a chat message. + + Attributes: + label: Button text displayed to the user (e.g. "Continue") + value: Machine-readable value sent back on click (e.g. "continue_limit") + style: Visual style - "primary", "danger", or "default" + """ + + label: str + value: str + style: str = "default" + + @dataclass class ChatMessage: """ @@ -44,6 +60,7 @@ class ChatMessage: message_id: Optional unique identifier for the message attachments: Optional list of file attachments task_session_id: Optional task session ID for reply feature + options: Optional list of interactive options/buttons """ sender: str @@ -53,6 +70,7 @@ class ChatMessage: message_id: Optional[str] = None attachments: Optional[List[Attachment]] = None task_session_id: Optional[str] = None + options: Optional[List[ChatMessageOption]] = None def __post_init__(self) -> None: """Generate message_id if not provided.""" diff --git a/app/ui_layer/controller/ui_controller.py b/app/ui_layer/controller/ui_controller.py index f65125bf..9ec34ae3 100644 --- a/app/ui_layer/controller/ui_controller.py +++ b/app/ui_layer/controller/ui_controller.py @@ -280,6 +280,21 @@ async def submit_message( await self._agent._handle_chat_message(payload) + async def handle_option_click(self, value: str, session_id: str) -> None: + """ + Handle a user clicking an option button in a chat message. + + Routes limit-choice options to the appropriate agent handler. + + Args: + value: The option value (e.g. "continue_limit", "abort_limit") + session_id: The task session ID associated with the option + """ + if value == "continue_limit": + await self._agent.handle_limit_continue(session_id) + elif value == "abort_limit": + await self._agent.handle_limit_abort(session_id) + # ───────────────────────────────────────────────────────────────────── # Event Processing # ───────────────────────────────────────────────────────────────────── diff --git a/app/usage/chat_storage.py b/app/usage/chat_storage.py index 17de5ffc..da85aa3e 100644 --- a/app/usage/chat_storage.py +++ b/app/usage/chat_storage.py @@ -34,6 +34,8 @@ class StoredChatMessage: timestamp: float attachments: Optional[List[Dict[str, Any]]] = None task_session_id: Optional[str] = None + options: Optional[List[Dict[str, Any]]] = None + option_selected: Optional[str] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" @@ -48,6 +50,10 @@ def to_dict(self) -> Dict[str, Any]: result["attachments"] = self.attachments if self.task_session_id: result["taskSessionId"] = self.task_session_id + if self.options: + result["options"] = self.options + if self.option_selected: + result["optionSelected"] = self.option_selected return result @@ -104,7 +110,7 @@ def _init_db(self) -> None: ON chat_messages(message_id) """) - # Migration: Add task_session_id column if it doesn't exist + # Migration: Add new columns if they don't exist cursor.execute("PRAGMA table_info(chat_messages)") columns = [col[1] for col in cursor.fetchall()] if "task_session_id" not in columns: @@ -113,6 +119,18 @@ def _init_db(self) -> None: ADD COLUMN task_session_id TEXT """) logger.info("[ChatStorage] Migrated: added task_session_id column") + if "options" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN options TEXT + """) + logger.info("[ChatStorage] Migrated: added options column") + if "option_selected" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN option_selected TEXT + """) + logger.info("[ChatStorage] Migrated: added option_selected column") conn.commit() @@ -130,8 +148,8 @@ def insert_message(self, message: StoredChatMessage) -> int: cursor = conn.cursor() cursor.execute(""" INSERT OR REPLACE INTO chat_messages - (message_id, sender, content, style, timestamp, attachments, task_session_id) - VALUES (?, ?, ?, ?, ?, ?, ?) + (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( message.message_id, message.sender, @@ -140,6 +158,8 @@ def insert_message(self, message: StoredChatMessage) -> int: message.timestamp, json.dumps(message.attachments) if message.attachments else None, message.task_session_id, + json.dumps(message.options) if message.options else None, + message.option_selected, )) conn.commit() return cursor.lastrowid @@ -162,7 +182,7 @@ def get_messages( with sqlite3.connect(self._db_path) as conn: cursor = conn.cursor() cursor.execute(""" - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected FROM chat_messages ORDER BY timestamp ASC LIMIT ? OFFSET ? @@ -178,6 +198,8 @@ def get_messages( timestamp=row[4], attachments=json.loads(row[5]) if row[5] else None, task_session_id=row[6], + options=json.loads(row[7]) if row[7] else None, + option_selected=row[8], ) for row in rows ] @@ -196,7 +218,7 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: cursor = conn.cursor() # Get last N messages ordered by timestamp DESC, then reverse cursor.execute(""" - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected FROM chat_messages ORDER BY timestamp DESC LIMIT ? @@ -212,6 +234,8 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: timestamp=row[4], attachments=json.loads(row[5]) if row[5] else None, task_session_id=row[6], + options=json.loads(row[7]) if row[7] else None, + option_selected=row[8], ) for row in rows ] @@ -234,6 +258,26 @@ def clear_messages(self) -> int: conn.commit() return count + def update_option_selected(self, message_id: str, option_value: str) -> bool: + """ + Mark which option was selected on a message. + + Args: + message_id: The message ID to update. + option_value: The value of the selected option. + + Returns: + True if the message was updated, False if not found. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE chat_messages SET option_selected = ? WHERE message_id = ?", + (option_value, message_id), + ) + conn.commit() + return cursor.rowcount > 0 + def delete_message(self, message_id: str) -> bool: """ Delete a message by ID. @@ -271,7 +315,7 @@ def get_messages_before( with sqlite3.connect(self._db_path) as conn: cursor = conn.cursor() cursor.execute(""" - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected FROM chat_messages WHERE timestamp < ? ORDER BY timestamp DESC @@ -288,6 +332,8 @@ def get_messages_before( timestamp=row[4], attachments=json.loads(row[5]) if row[5] else None, task_session_id=row[6], + options=json.loads(row[7]) if row[7] else None, + option_selected=row[8], ) for row in rows ] From d222492be3679907151cf933a886273c0c5a885d Mon Sep 17 00:00:00 2001 From: zfoong Date: Thu, 16 Apr 2026 13:07:51 +0900 Subject: [PATCH 5/6] feature:improve design, UX, and fixed bug --- agent_core/core/state/types.py | 2 +- app/agent_base.py | 60 ++++++++++++------- app/ui_layer/adapters/browser_adapter.py | 29 +++++++-- .../components/ui/StatusIndicator.module.css | 8 +++ .../src/components/ui/StatusIndicator.tsx | 4 +- .../frontend/src/pages/Chat/ChatMessage.tsx | 44 +++++++------- .../src/pages/Chat/ChatPage.module.css | 38 ++++++++---- .../browser/frontend/src/types/index.ts | 2 +- app/ui_layer/components/types.py | 2 + 9 files changed, 128 insertions(+), 61 deletions(-) diff --git a/agent_core/core/state/types.py b/agent_core/core/state/types.py index 6c069b4f..57540855 100644 --- a/agent_core/core/state/types.py +++ b/agent_core/core/state/types.py @@ -11,7 +11,7 @@ import logging # Default configuration values - can be overridden at runtime -DEFAULT_MAX_ACTIONS_PER_TASK = 10 +DEFAULT_MAX_ACTIONS_PER_TASK = 5 DEFAULT_MAX_TOKEN_PER_TASK = 6_000_000 # Use standard logging since loguru may not be available during import diff --git a/app/agent_base.py b/app/agent_base.py index d629741e..2e05e683 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -1276,7 +1276,7 @@ async def _check_agent_limits(self) -> bool: task_id=current_task_id, ) self.state_manager.bump_event_stream() - await self._send_limit_choice_message("action", action_count, max_actions, current_task_id) + await self._send_limit_choice_message("action", current_task_id) await self._pause_task_for_limit_choice(current_task_id) return False elif (action_count / max_actions) >= 0.8: @@ -1302,7 +1302,7 @@ async def _check_agent_limits(self) -> bool: task_id=current_task_id, ) self.state_manager.bump_event_stream() - await self._send_limit_choice_message("token", token_count, max_tokens, current_task_id) + await self._send_limit_choice_message("token", current_task_id) await self._pause_task_for_limit_choice(current_task_id) return False elif (token_count / max_tokens) >= 0.8: @@ -1322,13 +1322,20 @@ async def _check_agent_limits(self) -> bool: return True async def _send_limit_choice_message( - self, limit_type: str, current: int, maximum: int, session_id: str + self, limit_type: str, session_id: str ) -> None: """Send a chat message with Continue/Abort options when a limit is reached.""" label = "Action" if limit_type == "action" else "Token" - unit = "actions" if limit_type == "action" else "tokens" + + # Include task name so user knows which task hit the limit + task_name_suffix = "" + if self.task_manager: + task = self.task_manager.tasks.get(session_id) + if task and task.name: + task_name_suffix = f' for task "{task.name}"' + message = ( - f"{label} limit reached: {current}/{maximum} {unit} used. " + f"{label} limit reached{task_name_suffix}. " f"Would you like to continue (reset limits) or abort the task?" ) logger.info(f"[LIMIT] Sending limit choice message for session {session_id}: {message}") @@ -1382,19 +1389,21 @@ async def _pause_task_for_limit_choice(self, session_id: str) -> None: if task: task.waiting_for_user_reply = True - # Update UI state to "waiting" - if self.ui_controller: + # Update UI task status to "paused" - directly await to ensure + # the WebSocket broadcast completes before the react loop cleans up. + if self.ui_controller and self.ui_controller.active_adapter: + try: + action_panel = self.ui_controller.active_adapter.action_panel + if action_panel: + await action_panel.update_item(session_id, "paused") + except Exception as e: + logger.error(f"[LIMIT] Failed to update task status to paused: {e}", exc_info=True) + from app.ui_layer.events import UIEvent, UIEventType - self.ui_controller.event_bus.emit( - UIEvent( - type=UIEventType.TASK_UPDATE, - data={"task_id": session_id, "status": "waiting"}, - ) - ) self.ui_controller.event_bus.emit( UIEvent( type=UIEventType.AGENT_STATE_CHANGED, - data={"state": "waiting", "status_message": "Waiting for user decision..."}, + data={"state": "waiting", "status_message": "Paused - waiting for user decision..."}, ) ) @@ -1429,19 +1438,18 @@ async def handle_limit_continue(self, session_id: str) -> None: from agent_core.core.state.session import StateSession session = StateSession.get(session_id) if session: - session.agent_properties.set("action_count", 0) - session.agent_properties.set("token_count", 0) + session.agent_properties.set_property("action_count", 0) + session.agent_properties.set_property("token_count", 0) # Clear waiting flag task.waiting_for_user_reply = False - # Log to event stream + # Log to event stream as system message + task_label = f' for task "{task.name}"' if task.name else "" if self.event_stream_manager: + msg = f"User chose to continue{task_label}. Action and token counters have been reset." self.event_stream_manager.log( - "info", - "User chose to continue. Action and token counters have been reset.", - display_message=None, - task_id=session_id, + "system", msg, display_message=msg, task_id=session_id, ) self.state_manager.bump_event_stream() @@ -1467,8 +1475,18 @@ async def handle_limit_continue(self, session_id: str) -> None: async def handle_limit_abort(self, session_id: str) -> None: """User chose to abort after reaching limit.""" task = self.task_manager.tasks.get(session_id) if self.task_manager else None + task_label = f' for task "{task.name}"' if task and task.name else "" if task: task.waiting_for_user_reply = False + + # Log system message before cancelling (stream is removed during cancel) + if self.event_stream_manager: + msg = f"User chose to abort{task_label}. Task has been cancelled." + self.event_stream_manager.log( + "system", msg, display_message=msg, task_id=session_id, + ) + self.state_manager.bump_event_stream() + if self.task_manager: await self.task_manager.mark_task_cancel( reason="User chose to abort after reaching limit.", diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 879a8930..7869c34a 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -220,6 +220,7 @@ def _init_storage(self) -> None: attachments=attachments, task_session_id=stored.task_session_id, options=options, + option_selected=stored.option_selected, )) except Exception: # Storage may not be available, continue without persistence @@ -297,6 +298,8 @@ async def append_message(self, message: ChatMessage) -> None: {"label": o.label, "value": o.value, "style": o.style} for o in message.options ] + if message.option_selected: + message_data["optionSelected"] = message.option_selected await self._adapter._broadcast({ "type": "chat_message", @@ -365,6 +368,7 @@ def get_messages_before(self, before_timestamp: float, limit: int = 50) -> List[ message_id=s.message_id, attachments=attachments, options=options, + option_selected=s.option_selected, )) return messages except Exception: @@ -2026,12 +2030,18 @@ async def _handle_task_cancel(self, task_id: str) -> None: async def _handle_option_click(self, value: str, session_id: str, message_id: str) -> None: """Handle a user clicking an option button in a chat message.""" try: - # Mark the option as selected in storage - if self._chat and self._chat._storage and message_id: - try: - self._chat._storage.update_option_selected(message_id, value) - except Exception: - pass + # Mark the option as selected in storage and in-memory + if self._chat and message_id: + if self._chat._storage: + try: + self._chat._storage.update_option_selected(message_id, value) + except Exception: + pass + # Update in-memory message so refreshes reflect the selection + for m in self._chat._messages: + if m.message_id == message_id: + m.option_selected = value + break # Route to the controller await self._controller.handle_option_click(value, session_id) @@ -4375,6 +4385,8 @@ async def _handle_chat_history(self, before_timestamp: float, limit: int = 50) - {"label": o.label, "value": o.value, "style": o.style} for o in m.options ] + if m.option_selected: + msg_data["optionSelected"] = m.option_selected messages_data.append(msg_data) await self._broadcast({ @@ -4908,6 +4920,11 @@ def _get_initial_state(self) -> Dict[str, Any]: for att in m.attachments ]} if m.attachments else {}), **({"taskSessionId": m.task_session_id} if m.task_session_id else {}), + **({"options": [ + {"label": o.label, "value": o.value, "style": o.style} + for o in m.options + ]} if m.options else {}), + **({"optionSelected": m.option_selected} if m.option_selected else {}), } for m in self._chat.get_messages() ], diff --git a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css index 0be8b207..1d49fa06 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css @@ -37,6 +37,10 @@ color: #3b82f6; } +.paused { + color: #3b82f6; +} + /* Spinning animation for loader icon */ .spinning { animation: spin 1s linear infinite; @@ -101,6 +105,10 @@ background: #3b82f6; } +.dot_paused { + background: #3b82f6; +} + /* Pulse animation for active agent states */ .pulse { animation: pulse 1.5s ease-in-out infinite; diff --git a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx index 27ca201e..c76ec10c 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CheckCircle, XCircle, Loader, Clock, MessageCircle } from 'lucide-react' +import { CheckCircle, XCircle, Loader, Clock, MessageCircle, PauseCircle } from 'lucide-react' import styles from './StatusIndicator.module.css' import type { ActionStatus, AgentState } from '../../types' @@ -57,6 +57,8 @@ export function StatusIndicator({ return case 'waiting': return + case 'paused': + return case 'pending': case 'idle': default: diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx index dcef691a..26d077a1 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState, useMemo } from 'react' +import React, { memo, useState, useMemo, useRef } from 'react' import { Reply } from 'lucide-react' import { MarkdownContent, AttachmentDisplay, IconButton } from '../../components/ui' import type { ChatMessage as ChatMessageType } from '../../types' @@ -40,6 +40,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ const [optionClicked, setOptionClicked] = useState( message.optionSelected || null ) + const optionLockedRef = useRef(!!message.optionSelected) // Show reply for ALL agent messages const canReply = message.style === 'agent' && onReply @@ -87,6 +88,27 @@ export const ChatMessageItem = memo(function ChatMessageItem({
+ {message.options && message.options.length > 0 && ( +
+ Please select a response to continue: + {message.options.map((opt, index) => ( + + ))} +
+ )} {/* Reply button - positioned outside the bubble at top-right */} {canReply && isHovered && ( @@ -100,26 +122,6 @@ export const ChatMessageItem = memo(function ChatMessageItem({ /> )} - {/* Options below the bubble, same level as attachments */} - {message.options && message.options.length > 0 && ( -
- {message.options.map((opt) => ( - - ))} -
- )} {message.attachments && message.attachments.length > 0 && (
None: """Generate message_id if not provided.""" From c40141da52e185c970bd45c8553edcd5872473a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A4=E3=83=84=E3=83=9F=E3=83=8D?= Date: Thu, 16 Apr 2026 13:22:09 +0900 Subject: [PATCH 6/6] Update types.py Reset max actions per task to normal rate. --- agent_core/core/state/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent_core/core/state/types.py b/agent_core/core/state/types.py index 57540855..c4a95edd 100644 --- a/agent_core/core/state/types.py +++ b/agent_core/core/state/types.py @@ -11,7 +11,7 @@ import logging # Default configuration values - can be overridden at runtime -DEFAULT_MAX_ACTIONS_PER_TASK = 5 +DEFAULT_MAX_ACTIONS_PER_TASK = 150 DEFAULT_MAX_TOKEN_PER_TASK = 6_000_000 # Use standard logging since loguru may not be available during import