From 4d03bf906b237cc2801957ac0444931924523fc9 Mon Sep 17 00:00:00 2001 From: zfoong Date: Sun, 22 Mar 2026 08:34:21 +0900 Subject: [PATCH 1/6] bug:fix prompt and minor toggle button UI issue --- agent_core/core/prompts/context.py | 2 +- .../frontend/src/pages/Settings/SettingsPage.module.css | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/agent_core/core/prompts/context.py b/agent_core/core/prompts/context.py index 11833f75..58e5f86a 100644 --- a/agent_core/core/prompts/context.py +++ b/agent_core/core/prompts/context.py @@ -250,7 +250,7 @@ - When you identify a proactive opportunity: 1. Acknowledge the potential for automation 2. Ask the user if they would like you to set up a proactive task (can be recurring task, one-time immediate task, or one-time task scheduled for later) - 3. If approved, use `proactive_add` action to add recurring task to PROACTIVE.md or `schedule_task` action to add one-time proactive task. + 3. If approved, use `recurring_add` action to add recurring task to PROACTIVE.md or `schedule_task` action to add one-time proactive task. 4. Confirm the setup with the user IMPORTANT: DO NOT automatically create proactive tasks without user consent. Always ask first. diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css index 86c199bc..75d602b5 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css @@ -232,13 +232,14 @@ .toggle::before { content: ''; position: absolute; - top: 2px; + top: 50%; left: 2px; width: 18px; height: 18px; background: var(--color-white); border-radius: 50%; transition: transform var(--transition-fast); + transform: translateY(-50%); } .toggle:checked { @@ -246,7 +247,7 @@ } .toggle:checked::before { - transform: translateX(18px); + transform: translateY(-50%) translateX(18px); } /* Action Group */ From 8e0f5bf79896a9176400f39f4835833cf9f0e566 Mon Sep 17 00:00:00 2001 From: zfoong Date: Sun, 22 Mar 2026 17:45:33 +0900 Subject: [PATCH 2/6] improvement:reject large attachment before send --- app/ui_layer/adapters/browser_adapter.py | 37 ++++++++++++++++--- .../src/contexts/WebSocketContext.tsx | 26 +++++++++---- .../frontend/src/pages/Chat/ChatPage.tsx | 10 +++-- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 78b737f9..c8b01c90 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -889,16 +889,23 @@ async def _websocket_handler(self, request: "web.Request") -> "web.WebSocketResp break except json.JSONDecodeError as e: # Continue on JSON errors, don't close connection - pass + import traceback + error_detail = f"JSON decode error: {e}" + print(f"[BROWSER ADAPTER] {error_detail}") + await self._broadcast_error_to_chat(error_detail) except Exception as e: # Continue on message errors, don't close connection - pass + import traceback + error_detail = f"WebSocket message error: {type(e).__name__}: {e}\n{traceback.format_exc()}" + print(f"[BROWSER ADAPTER] {error_detail}") + await self._broadcast_error_to_chat(error_detail) except asyncio.CancelledError: - pass - except (ClientConnectionResetError, ConnectionResetError): - pass # Silently handle expected connection errors + print("[BROWSER ADAPTER] WebSocket cancelled") + except (ClientConnectionResetError, ConnectionResetError) as e: + print(f"[BROWSER ADAPTER] WebSocket connection reset: {type(e).__name__}: {e}") except Exception as e: - pass + import traceback + print(f"[BROWSER ADAPTER] WebSocket loop error: {type(e).__name__}: {e}\n{traceback.format_exc()}") finally: self._ws_clients.discard(ws) @@ -3240,6 +3247,24 @@ async def _broadcast(self, message: Dict[str, Any]) -> None: # Clean up disconnected clients self._ws_clients -= disconnected + async def _broadcast_error_to_chat(self, error_message: str) -> None: + """Broadcast an error message to the chat panel for debugging.""" + import time + try: + await self._broadcast({ + "type": "chat_message", + "data": { + "sender": "System", + "content": f"[DEBUG ERROR] {error_message}", + "style": "error", + "timestamp": time.time(), + "messageId": f"error:{time.time()}", + }, + }) + except Exception: + # If broadcast fails, at least print to console + print(f"[BROWSER ADAPTER] Failed to broadcast error: {error_message}") + async def _broadcast_metrics_loop(self) -> None: """Periodically broadcast dashboard metrics to connected clients.""" while self._running: diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 8ad9a226..bc5295e5 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -158,8 +158,8 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } } - ws.onclose = () => { - console.log('[WS] Disconnected, reconnectCount =', reconnectCountRef.current) + ws.onclose = (event) => { + console.log('[WS] Disconnected, code:', event.code, 'reason:', event.reason, 'wasClean:', event.wasClean, 'reconnectCount:', reconnectCountRef.current) isConnectingRef.current = false setState(prev => ({ ...prev, @@ -485,12 +485,22 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { const sendMessage = useCallback((content: string, attachments?: PendingAttachment[], replyContext?: ReplyContext) => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - type: 'message', - content, - attachments: attachments || [], - replyContext: replyContext || null, - })) + try { + const payload = { + type: 'message', + content, + attachments: attachments || [], + replyContext: replyContext || null, + } + const payloadStr = JSON.stringify(payload) + console.log('[WebSocket] Sending message, payload size:', payloadStr.length, 'bytes, attachments:', attachments?.length || 0) + wsRef.current.send(payloadStr) + console.log('[WebSocket] Message sent successfully') + } catch (error) { + console.error('[WebSocket] Error sending message:', error) + } + } else { + console.warn('[WebSocket] Cannot send message - WebSocket not open, state:', wsRef.current?.readyState) } }, []) diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 43839257..80d942eb 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -22,8 +22,10 @@ const MIN_PANEL_WIDTH = 200 const MAX_PANEL_WIDTH = 800 // Attachment limits +// Backend WebSocket has 100MB limit, base64 encoding adds ~33% overhead +// So raw file limit should be ~70MB to stay safely under the WebSocket limit const MAX_ATTACHMENT_COUNT = 10 -const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024 * 1024 // 50GB +const MAX_TOTAL_SIZE_BYTES = 70 * 1024 * 1024 // 70MB (leaves room for base64 encoding + JSON overhead) // Format file size for display const formatFileSize = (bytes: number): string => { @@ -76,7 +78,7 @@ export function ChatPage() { if (totalSize > MAX_TOTAL_SIZE_BYTES) { return { valid: false, - error: `Total size (${formatFileSize(totalSize)}) exceeds 50GB limit. Please remove some files or copy large files directly to the agent workspace.` + error: `Total size (${formatFileSize(totalSize)}) exceeds 70MB limit. Please remove some files or copy large files directly to the agent workspace.` } } return { valid: true, error: null } @@ -298,14 +300,14 @@ export function ChatPage() { // Check individual file size (for very large files, recommend manual copy) if (file.size > MAX_TOTAL_SIZE_BYTES) { - setAttachmentError(`File "${file.name}" (${formatFileSize(file.size)}) exceeds the 50GB limit. For very large files, please copy them directly to the agent workspace folder.`) + setAttachmentError(`File "${file.name}" (${formatFileSize(file.size)}) exceeds the 70MB limit. For very large files, please copy them directly to the agent workspace folder.`) e.target.value = '' return } // Check if adding this file would exceed total size limit if (newTotalSize + file.size > MAX_TOTAL_SIZE_BYTES) { - setAttachmentError(`Adding "${file.name}" would exceed the 50GB total size limit. Current total: ${formatFileSize(newTotalSize)}. For large files, please copy them directly to the agent workspace folder.`) + setAttachmentError(`Adding "${file.name}" would exceed the 70MB total size limit. Current total: ${formatFileSize(newTotalSize)}. For large files, please copy them directly to the agent workspace folder.`) e.target.value = '' return } From d58422bb6ca46280da4136c523c4645f5de58896 Mon Sep 17 00:00:00 2001 From: zfoong Date: Tue, 24 Mar 2026 21:44:17 +0900 Subject: [PATCH 3/6] bug:schedule task bug fixed --- agent_core/core/impl/config/watcher.py | 13 +- app/agent_base.py | 7 + app/data/action/schedule_task.py | 76 +--------- app/onboarding/soft/task_creator.py | 2 +- app/scheduler/manager.py | 189 ++++++++++++++++++++++--- 5 files changed, 182 insertions(+), 105 deletions(-) diff --git a/agent_core/core/impl/config/watcher.py b/agent_core/core/impl/config/watcher.py index 86a6eb88..afd57e13 100644 --- a/agent_core/core/impl/config/watcher.py +++ b/agent_core/core/impl/config/watcher.py @@ -233,25 +233,20 @@ def _trigger_reload(self, file_path: Path) -> None: # Check if callback is async if asyncio.iscoroutinefunction(callback): if self._event_loop and self._event_loop.is_running(): - # Schedule in the event loop - asyncio.run_coroutine_threadsafe(callback(), self._event_loop) + # Schedule in the event loop (non-blocking) + future = asyncio.run_coroutine_threadsafe(callback(), self._event_loop) + future.add_done_callback(lambda f: f.exception()) # Suppress unhandled exception warning else: - # Create new event loop for this thread asyncio.run(callback()) else: - # Sync callback callback() # Update last modified time if config.path.exists(): config.last_modified = config.path.stat().st_mtime - logger.info(f"[CONFIG_WATCHER] Reload complete for {file_path.name}") - except Exception as e: - logger.error(f"[CONFIG_WATCHER] Reload failed for {file_path.name}: {e}") - import traceback - logger.debug(traceback.format_exc()) + logger.warning(f"[CONFIG_WATCHER] Reload error for {file_path.name}: {e}") # Global singleton instance diff --git a/app/agent_base.py b/app/agent_base.py index 6fba7b2a..ea321212 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -2366,6 +2366,13 @@ def print_startup_step(step: int, total: int, message: str): ) await self.scheduler.start() + # Register scheduler_config for hot-reload (after scheduler is initialized) + config_watcher.register( + scheduler_config_path, + self.scheduler.reload, + name="scheduler_config.json" + ) + # Trigger soft onboarding if needed (BEFORE starting interface) # This ensures agent handles onboarding logic, not the interfaces from app.onboarding import onboarding_manager diff --git a/app/data/action/schedule_task.py b/app/data/action/schedule_task.py index 51bad01b..8d98967c 100644 --- a/app/data/action/schedule_task.py +++ b/app/data/action/schedule_task.py @@ -82,7 +82,7 @@ } } ) -def schedule_task(input_data: dict) -> dict: +async def schedule_task(input_data: dict) -> dict: """Add a new scheduled task or queue an immediate trigger.""" import app.internal_action_interface as iai from datetime import datetime @@ -114,8 +114,7 @@ def schedule_task(input_data: dict) -> dict: # Handle immediate execution if schedule_expr.lower() == "immediate": - return _add_immediate_trigger( - scheduler=scheduler, + return await scheduler.queue_immediate_trigger( name=name, instruction=instruction, priority=priority, @@ -165,74 +164,3 @@ def schedule_task(input_data: dict) -> dict: "status": "error", "error": str(e) } - - -def _add_immediate_trigger( - scheduler, - name: str, - instruction: str, - priority: int, - mode: str, - action_sets: list, - skills: list, - payload: dict -) -> dict: - """ - Queue a trigger for immediate execution. - - This creates a new session and queues it to the TriggerQueue - for immediate processing by the scheduler. - """ - import asyncio - import time - import uuid - from agent_core import Trigger - - # Generate unique session ID - session_id = f"immediate_{uuid.uuid4().hex[:8]}_{int(time.time())}" - - # Build trigger payload (matching the format used by _fire_schedule) - trigger_payload = { - "type": "scheduled", - "schedule_id": f"immediate_{uuid.uuid4().hex[:8]}", - "schedule_name": name, - "instruction": instruction, - "mode": mode, - "action_sets": action_sets, - "skills": skills, - **payload - } - - # Create trigger - trigger = Trigger( - fire_at=time.time(), # Fire immediately - priority=priority, - next_action_description=f"[Immediate] {name}: {instruction}", - payload=trigger_payload, - session_id=session_id, - ) - - # Queue the trigger - trigger_queue = scheduler._trigger_queue - if trigger_queue is None: - return { - "status": "error", - "error": "Trigger queue not initialized" - } - - # Try to queue using running event loop, or create new one - try: - loop = asyncio.get_running_loop() - # We're in an async context, use create_task - asyncio.create_task(trigger_queue.put(trigger)) - except RuntimeError: - # No running event loop, use asyncio.run - asyncio.run(trigger_queue.put(trigger)) - - return { - "status": "ok", - "schedule_id": session_id, - "name": name, - "scheduled_for": "immediate", - "message": f"Task '{name}' queued for immediate execution (session: {session_id})" - } diff --git a/app/onboarding/soft/task_creator.py b/app/onboarding/soft/task_creator.py index 02859650..dbbd2fd5 100644 --- a/app/onboarding/soft/task_creator.py +++ b/app/onboarding/soft/task_creator.py @@ -75,7 +75,7 @@ def create_soft_onboarding_task(task_manager: "TaskManager") -> str: task_id = task_manager.create_task( task_name="User Profile Interview", task_instruction=SOFT_ONBOARDING_TASK_INSTRUCTION, - mode="complex", + mode="simple", action_sets=["file_operations", "core"], selected_skills=["user-profile-interview"] ) diff --git a/app/scheduler/manager.py b/app/scheduler/manager.py index c9870123..b8edec41 100644 --- a/app/scheduler/manager.py +++ b/app/scheduler/manager.py @@ -164,13 +164,14 @@ def add_schedule( # Add to schedules self._schedules[schedule_id] = task - # Save config - self._save_config() - - # Start loop if running and enabled + # Start loop if running and enabled (BEFORE saving config) + # This ensures the loop is in _scheduler_tasks when reload() runs if self._is_running and enabled: asyncio.create_task(self._start_schedule_loop(schedule_id)) + # Save config (triggers hot-reload via file watcher) + self._save_config() + logger.info(f"[SCHEDULER] Added schedule: {schedule_id} - {name}") return schedule_id @@ -276,6 +277,78 @@ def get_schedule(self, schedule_id: str) -> Optional[ScheduledTask]: """Get a schedule by ID.""" return self._schedules.get(schedule_id) + async def queue_immediate_trigger( + self, + name: str, + instruction: str, + priority: int = 50, + mode: str = "simple", + action_sets: Optional[List[str]] = None, + skills: Optional[List[str]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Queue a trigger for immediate execution. + + Creates a new session and queues it to the TriggerQueue + for immediate processing by the scheduler. + + Args: + name: Human-readable name for the task + instruction: What the agent should do + priority: Trigger priority (lower = higher priority) + mode: Task mode ("simple" or "complex") + action_sets: Action sets to enable for the task + skills: Skills to load for the task + payload: Additional payload data to pass to the task + + Returns: + Dictionary with status, session_id, and message + """ + if not self._trigger_queue: + return { + "status": "error", + "error": "Trigger queue not initialized" + } + + # Generate unique session ID + session_id = f"immediate_{uuid.uuid4().hex[:8]}_{int(time.time())}" + + # Build trigger payload (matching the format used by _fire_schedule) + trigger_payload = { + "type": "scheduled", + "schedule_id": f"immediate_{uuid.uuid4().hex[:8]}", + "schedule_name": name, + "instruction": instruction, + "mode": mode, + "action_sets": action_sets or [], + "skills": skills or [], + **(payload or {}), + } + + # Create trigger + trigger = Trigger( + fire_at=time.time(), # Fire immediately + priority=priority, + next_action_description=f"[Immediate] {name}: {instruction}", + payload=trigger_payload, + session_id=session_id, + ) + + # Queue the trigger + await self._trigger_queue.put(trigger) + + logger.info(f"[SCHEDULER] Queued immediate trigger: {name} (session: {session_id})") + + return { + "status": "ok", + "schedule_id": session_id, + "name": name, + "recurring": False, + "scheduled_for": "immediate", + "message": f"Task '{name}' queued for immediate execution (session: {session_id})" + } + def get_status(self) -> Dict[str, Any]: """Get scheduler status for monitoring.""" return { @@ -296,18 +369,76 @@ def get_status(self) -> Dict[str, Any]: ], } + async def reload(self, config_path: Optional[Path] = None) -> Dict[str, Any]: + """ + Hot-reload scheduler configuration from disk. + + Stops all loops, clears schedules, re-reads config, and restarts. + """ + try: + # 1. Stop all existing loops + for schedule_id in list(self._scheduler_tasks.keys()): + await self._stop_schedule_loop(schedule_id) + + # 2. Clear schedules + self._schedules.clear() + + # 3. Load config from disk + config = self._load_config() + self._master_enabled = config.enabled + + if not config.enabled: + logger.info("[SCHEDULER] Scheduler disabled in config") + return {"success": True, "message": "Scheduler disabled", "total": 0} + + # 4. Add schedules and start loops + for task in config.schedules: + self._schedules[task.id] = task + if self._is_running and task.enabled: + await self._start_schedule_loop(task.id) + + logger.info(f"[SCHEDULER] Reloaded {len(self._schedules)} schedule(s)") + return { + "success": True, + "message": f"Reloaded {len(self._schedules)} schedules", + "total": len(self._schedules) + } + except Exception as e: + logger.error(f"[SCHEDULER] Reload failed: {e}") + return {"success": False, "message": str(e), "total": 0} + + async def _stop_schedule_loop(self, schedule_id: str) -> None: + """Stop a background loop for a schedule.""" + if schedule_id not in self._scheduler_tasks: + return + + task = self._scheduler_tasks[schedule_id] + if not task.done(): + try: + task.cancel() + await task + except (asyncio.CancelledError, RuntimeError, Exception): + pass # Ignore all errors during cancellation + + del self._scheduler_tasks[schedule_id] + # ─────────────── Internal Methods ─────────────── async def _start_schedule_loop(self, schedule_id: str) -> None: """Start a background loop for a schedule.""" if schedule_id in self._scheduler_tasks: - return # Already running + existing_task = self._scheduler_tasks[schedule_id] + if not existing_task.done(): + return # Already running + # Task exists but is done - clean up before creating new one + del self._scheduler_tasks[schedule_id] + logger.debug(f"[SCHEDULER] Cleaned up done task for: {schedule_id}") task = asyncio.create_task(self._schedule_loop(schedule_id)) self._scheduler_tasks[schedule_id] = task schedule = self._schedules[schedule_id] - logger.debug(f"[SCHEDULER] Started loop for: {schedule_id} - {schedule.name}") + logger.info(f"[SCHEDULER] Started loop for: {schedule_id} - {schedule.name}") async def _schedule_loop(self, schedule_id: str) -> None: """ @@ -315,10 +446,16 @@ async def _schedule_loop(self, schedule_id: str) -> None: Calculates delay to next fire time, sleeps, then fires the trigger. """ + logger.info(f"[SCHEDULER] Loop starting for: {schedule_id}") + while self._is_running: try: schedule = self._schedules.get(schedule_id) - if not schedule or not schedule.enabled: + if not schedule: + logger.warning(f"[SCHEDULER] Schedule {schedule_id} not found, exiting loop") + break + if not schedule.enabled: + logger.info(f"[SCHEDULER] Schedule {schedule_id} disabled, exiting loop") break # Calculate next fire time @@ -332,18 +469,27 @@ async def _schedule_loop(self, schedule_id: str) -> None: delay = next_fire - now if delay > 0: next_fire_str = datetime.fromtimestamp(next_fire).strftime("%Y-%m-%d %H:%M:%S") - logger.debug( - f"[SCHEDULER] {schedule_id} sleeping until {next_fire_str} " - f"({delay / 3600:.2f} hours)" + logger.info( + f"[SCHEDULER] {schedule_id} ({schedule.name}) sleeping until {next_fire_str} " + f"({delay:.1f}s / {delay / 60:.1f}min)" ) await asyncio.sleep(delay) # Check if still running and schedule still exists schedule = self._schedules.get(schedule_id) - if not schedule or not schedule.enabled or not self._is_running: + logger.info(f"[SCHEDULER] {schedule_id} woke up, checking conditions before fire") + if not schedule: + logger.warning(f"[SCHEDULER] {schedule_id} schedule was removed while sleeping") + break + if not schedule.enabled: + logger.info(f"[SCHEDULER] {schedule_id} was disabled while sleeping") + break + if not self._is_running: + logger.info(f"[SCHEDULER] {schedule_id} scheduler stopped while sleeping") break # Fire the schedule + logger.info(f"[SCHEDULER] {schedule_id} about to fire!") await self._fire_schedule(schedule) # Small delay before recalculating (for interval schedules) @@ -354,13 +500,17 @@ async def _schedule_loop(self, schedule_id: str) -> None: await asyncio.sleep(60) except asyncio.CancelledError: - logger.debug(f"[SCHEDULER] Loop cancelled for: {schedule_id}") + logger.info(f"[SCHEDULER] Loop cancelled for: {schedule_id}") break except Exception as e: - logger.warning(f"[SCHEDULER] Error in loop for {schedule_id}: {e}") + logger.error(f"[SCHEDULER] Error in loop for {schedule_id}: {e}") + import traceback + logger.error(f"[SCHEDULER] Traceback: {traceback.format_exc()}") # Wait before retrying to avoid tight error loops await asyncio.sleep(60) + logger.info(f"[SCHEDULER] Loop exited for: {schedule_id}") + async def _fire_schedule(self, schedule: ScheduledTask) -> None: """ Fire a scheduled task trigger. @@ -426,8 +576,8 @@ def _load_config(self) -> SchedulerConfig: try: with open(self._config_path, "r", encoding="utf-8") as f: data = json.load(f) - except json.JSONDecodeError as e: - logger.error(f"[SCHEDULER] Invalid JSON in config: {e}") + except (json.JSONDecodeError, Exception) as e: + logger.warning(f"[SCHEDULER] Config read error, using defaults: {e}") return SchedulerConfig() # Parse schedules @@ -466,13 +616,10 @@ def _save_config(self) -> None: # Ensure directory exists self._config_path.parent.mkdir(parents=True, exist_ok=True) - # Write atomically (write to temp, then rename) - temp_path = self._config_path.with_suffix(".tmp") + # Write directly to file (not atomic) to trigger watchdog on_modified event + # Note: Atomic write (temp + replace) doesn't trigger on_modified on Windows try: - with open(temp_path, "w", encoding="utf-8") as f: + with open(self._config_path, "w", encoding="utf-8") as f: json.dump(config.to_dict(), f, indent=2) - temp_path.replace(self._config_path) except Exception as e: logger.error(f"[SCHEDULER] Failed to save config: {e}") - if temp_path.exists(): - temp_path.unlink() From 80b43331744060649084335a39c131b81593b7dc Mon Sep 17 00:00:00 2001 From: zfoong Date: Wed, 25 Mar 2026 06:59:19 +0900 Subject: [PATCH 4/6] feature:Add starting system message --- app/ui_layer/adapters/browser_adapter.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index c8b01c90..35b6bd1f 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -759,6 +759,25 @@ def _handle_reasoning(self, event: UIEvent) -> None: async def _on_start(self) -> None: """Start the browser interface.""" from aiohttp import web + from app.onboarding import onboarding_manager + import uuid + + # Display welcome system message if soft onboarding is pending + if onboarding_manager.needs_soft_onboarding: + welcome_message = ChatMessage( + sender="System", + content="""**Welcome to CraftBot** + +CraftBot can perform virtually any computer-based task by configuring the right MCP servers, skills, or connecting to apps. + +If you need help setting up MCP servers or skills, just ask the agent. + +A quick Q&A will now begin to understand your preferences and serve you better:""", + style="system", + timestamp=time.time(), + message_id=f"welcome-{uuid.uuid4().hex[:8]}", + ) + self._chat._messages.insert(0, welcome_message) self._app = web.Application() From 19b2d1a30e4fa768cfd8f0fca161ac46209bbd9a Mon Sep 17 00:00:00 2001 From: zfoong Date: Fri, 27 Mar 2026 17:50:00 +0900 Subject: [PATCH 5/6] improvement:update soft-onboarding --- agent_core/core/prompts/action.py | 14 +++++------ app/agent_base.py | 16 ++++++------ app/onboarding/soft/task_creator.py | 34 +++++++++++++++++++------- skills/user-profile-interview/SKILL.md | 8 +++--- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index 60b7329c..e53b7952 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -51,9 +51,9 @@ - If platform is WhatsApp → MUST use send_whatsapp_web_text_message (use to="user" for self-messages) - If platform is Discord → MUST use send_discord_message or send_discord_dm - If platform is Slack → MUST use send_slack_message -- If platform is CraftBot TUI (or no platform specified) → use send_message +- If platform is CraftBot interface (or no platform specified) → use send_message - ONLY fall back to send_message if the platform's send action is not in the available actions list. -- send_message is for local TUI display ONLY. It does NOT reach external platforms. +- send_message is for local interface display ONLY. It does NOT reach external platforms. Third-Party Message Handling: - Third-party messages show as "[Incoming X message from NAME]" in event stream. @@ -67,7 +67,7 @@ Preferred Platform Routing (for notifications): - Check USER.md for "Preferred Messaging Platform" setting when notifying user. - For notifications about third-party messages, use preferred platform if available. -- If preferred platform's send action is unavailable, fall back to send_message (TUI). +- If preferred platform's send action is unavailable, fall back to send_message (interface). @@ -164,9 +164,9 @@ - If platform is WhatsApp → MUST use send_whatsapp_web_text_message (use to="user" for self-messages) - If platform is Discord → MUST use send_discord_message or send_discord_dm - If platform is Slack → MUST use send_slack_message -- If platform is CraftBot TUI (or no platform specified) → use send_message +- If platform is CraftBot interface (or no platform specified) → use send_message - ONLY fall back to send_message if the platform's send action is not in the available actions list. -- send_message is for local TUI display ONLY. It does NOT reach external platforms. +- send_message is for local interface display ONLY. It does NOT reach external platforms. Adaptive Execution: - If you lack information during EXECUTE, go back to COLLECT phase (add new collect todos) @@ -395,9 +395,9 @@ - If platform is WhatsApp → MUST use send_whatsapp_web_text_message (use to="user" for self-messages) - If platform is Discord → MUST use send_discord_message or send_discord_dm - If platform is Slack → MUST use send_slack_message -- If platform is CraftBot TUI (or no platform specified) → use send_message +- If platform is CraftBot interface (or no platform specified) → use send_message - ONLY fall back to send_message if the platform's send action is not in the available actions list. -- send_message is for local TUI display ONLY. It does NOT reach external platforms. +- send_message is for local interface display ONLY. It does NOT reach external platforms. Action Selection: - Choose the most direct action to accomplish the goal diff --git a/app/agent_base.py b/app/agent_base.py index ea321212..627e24c7 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -97,7 +97,7 @@ class TriggerData: parent_id: str | None session_id: str | None = None user_message: str | None = None # Original user message without routing prefix - platform: str | None = None # Source platform (e.g., "CraftBot TUI", "Telegram", "Whatsapp") + platform: str | None = None # Source platform (e.g., "CraftBot Interface", "Telegram", "Whatsapp") is_self_message: bool = False # True when the user sent themselves a message contact_id: str | None = None # Sender/chat ID from external platform channel_id: str | None = None # Channel/group ID from external platform @@ -243,7 +243,7 @@ def __init__( context_engine=self.context_engine, ) - # Initialize footage callback (will be set by TUI interface later) + # Initialize footage callback (will be set by CraftBot interface later) self._tui_footage_callback = None # Only initialize GUIModule if GUI mode is globally enabled @@ -563,10 +563,10 @@ async def _handle_memory_processing_trigger(self) -> bool: def _extract_trigger_data(self, trigger: Trigger) -> TriggerData: """Extract and structure data from trigger.""" # Extract platform from payload (already formatted by _handle_chat_message) - # Default to "CraftBot TUI" for local messages without platform info + # Default to "CraftBot Interface" for local messages without platform info payload = trigger.payload or {} raw_platform = payload.get("platform", "") - platform = raw_platform if raw_platform else "CraftBot TUI" + platform = raw_platform if raw_platform else "CraftBot Interface" return TriggerData( query=trigger.next_action_description, @@ -1517,13 +1517,13 @@ async def _handle_chat_message(self, payload: Dict): # Determine platform - use payload's platform if available, otherwise default # External messages (WhatsApp, Telegram, etc.) have platform set by _handle_external_event - # TUI/CLI messages don't have platform in payload, so use "CraftBot TUI" + # Interface/CLI messages don't have platform in payload, so use "CraftBot Interface" if payload.get("platform"): # External message - capitalize for display (e.g., "whatsapp" -> "Whatsapp") platform = payload["platform"].capitalize() else: - # Local TUI/CLI message - platform = "CraftBot TUI" + # Local Interface/CLI message + platform = "CraftBot Interface" # Direct reply bypass - skip routing LLM when target_session_id is provided target_session_id = payload.get("target_session_id") @@ -1675,7 +1675,7 @@ async def _handle_chat_message(self, payload: Dict): # the correct platform-specific send action for replies. # Must be directive (not just informational) for weaker LLMs. platform_hint = "" - if platform and platform.lower() != "craftbot tui": + if platform and platform.lower() != "craftbot interface": platform_hint = f" from {platform} (reply on {platform}, NOT send_message)" await self.triggers.put( diff --git a/app/onboarding/soft/task_creator.py b/app/onboarding/soft/task_creator.py index dbbd2fd5..b7d36468 100644 --- a/app/onboarding/soft/task_creator.py +++ b/app/onboarding/soft/task_creator.py @@ -22,25 +22,34 @@ INTERVIEW FLOW (4 batches): -1. IDENTITY BATCH - Start with warm greeting and ask together: +1. Warm Introduction + Identity Questions +Start with a friendly greeting and ask the first batch using a numbered list: - What should I call you? - What do you do for work? - Where are you based? (Infer timezone from their location, keep this silent) -2. PREFERENCES BATCH - Ask together: + Example opening: + > "Hi there! I'm excited to be your new AI assistant. To personalize your experience, let me ask a few quick questions: + > 1. What should I call you? + > 2. What do you do for work? + > 3. Where are you based?" + +2. Preference Questions (Combined) - What language do you prefer me to communicate in? - Do you prefer casual or formal communication? - Should I proactively suggest things or wait for instructions? - What types of actions should I ask your approval for? -3. MESSAGING PLATFORM: - - Which messaging platform should I use for notifications? (Telegram/WhatsApp/Discord/Slack/TUI only) +3. Messaging Platform + - Which messaging platform should I use for notifications? (Telegram/WhatsApp/Discord/Slack/CraftBot Interface only) -4. LIFE GOALS (most important question): +4. Life Goals & Assistance - What are your life goals or aspirations? - What would you like me to help you with generally? +Refer to the "user-profile-interview" skill for questions and style. + IMPORTANT GUIDELINES: - Ask related questions together using a numbered list format - Be warm and conversational, not robotic @@ -50,10 +59,17 @@ - If user is annoyed by this interview or refuse to answer, just skip, and end task. After gathering ALL information: -1. Read agent_file_system/USER.md -2. Update USER.md with the collected information using stream_edit (including Language in Communication Preferences and Life Goals section) -3. Suggest 3-5 specific tasks that can help them achieve their life goals using CraftBot's automation capabilities -4. End the task immediately with task_end (do NOT wait for confirmation) +1. Tell the user to wait a moment while you update their preference +2. Read agent_file_system/USER.md +3. Update USER.md with the collected information using stream_edit (including Language in Communication Preferences and Life Goals section) +4. Suggest tasks based on life goals: Send a message suggesting 1-3 tasks that CraftBot can help with to improve their life and get closer to achieving their goals. Focus on: + - Tasks that leverage CraftBot's automation capabilities + - Recurring tasks that save time in the long run + - Immediate tasks that can show impact in short-term + - Bite-size tasks that is specialized, be specific with numbers or actionable items. DO NOT suggest generic task. + - Avoid giving mutliple approaches in each suggested task, provide the BEST option to achieve goal. + - Tasks that align with their work and personal aspirations +5. End the task immediately with task_end (do NOT wait for confirmation) Start with: "Hi! I'm excited to be your AI assistant. To personalize your experience, let me ask a few quick questions:" then list the first batch. """ diff --git a/skills/user-profile-interview/SKILL.md b/skills/user-profile-interview/SKILL.md index 04f47cf9..39cfbeae 100644 --- a/skills/user-profile-interview/SKILL.md +++ b/skills/user-profile-interview/SKILL.md @@ -73,10 +73,12 @@ Note any personality traits, preferences, or working style observations from the 3. **Update AGENT.md** if user provided a name for the agent: - Update the "Agent Given Name" field -4. **Suggest tasks based on life goals**: Send a message suggesting 3-5 specific tasks that CraftBot can help with to improve their life and get closer to achieving their goals. Focus on: +4. **Suggest tasks based on life goals**: Send a message suggesting 1-3 tasks that CraftBot can help with to improve their life and get closer to achieving their goals. Focus on: - Tasks that leverage CraftBot's automation capabilities - - Recurring tasks that save time - - Goal-tracking or accountability tasks + - Recurring tasks that save time in the long run + - Immediate tasks that can show impact in short-term + - Bite-size tasks that is specialized, be specific with numbers or actionable items. DO NOT suggest generic task. + - Avoid giving mutliple approaches in each suggested task, provide the BEST option to achieve goal. - Tasks that align with their work and personal aspirations 5. **End task immediately**: Use `task_end` right after suggesting tasks. Do NOT wait for confirmation or ask if information is accurate. From 07ab43a1ddc8c6f9b8b2fac17848028baa063c7c Mon Sep 17 00:00:00 2001 From: zfoong Date: Fri, 27 Mar 2026 18:36:52 +0900 Subject: [PATCH 6/6] bug/handle send message with attachment too --- app/data/action/send_message.py | 10 ++++-- .../action/send_message_with_attachment.py | 4 ++- app/internal_action_interface.py | 36 +++++++++++++++---- app/ui_layer/adapters/browser_adapter.py | 5 +++ 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index b9cdb900..c2661b14 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -39,15 +39,19 @@ def send_message(input_data: dict) -> dict: import json import asyncio - + message = input_data['message'] wait_for_user_reply = bool(input_data.get('wait_for_user_reply', False)) simulated_mode = input_data.get('simulated_mode', False) - + # Extract session_id injected by ActionManager for multi-task isolation + session_id = input_data.get('_session_id') + # In simulated mode, skip the actual interface call for testing if not simulated_mode: import app.internal_action_interface as internal_action_interface - asyncio.run(internal_action_interface.InternalActionInterface.do_chat(message)) + asyncio.run(internal_action_interface.InternalActionInterface.do_chat( + message, session_id=session_id + )) fire_at_delay = 10800 if wait_for_user_reply else 0 # Return 'success' for test compatibility, but keep 'ok' in production if needed diff --git a/app/data/action/send_message_with_attachment.py b/app/data/action/send_message_with_attachment.py index a9e7cdbf..3662c3b2 100644 --- a/app/data/action/send_message_with_attachment.py +++ b/app/data/action/send_message_with_attachment.py @@ -60,6 +60,8 @@ def send_message_with_attachment(input_data: dict) -> dict: file_paths = input_data.get('file_paths', []) wait_for_user_reply = bool(input_data.get('wait_for_user_reply', False)) simulated_mode = input_data.get('simulated_mode', False) + # Extract session_id injected by ActionManager for multi-task isolation + session_id = input_data.get('_session_id') # Ensure file_paths is a list if isinstance(file_paths, str): @@ -78,7 +80,7 @@ def send_message_with_attachment(input_data: dict) -> dict: # Use the do_chat_with_attachments method which handles browser/CLI fallback result = asyncio.run(internal_action_interface.InternalActionInterface.do_chat_with_attachments( - message, file_paths + message, file_paths, session_id=session_id )) fire_at_delay = 10800 if wait_for_user_reply else 0 diff --git a/app/internal_action_interface.py b/app/internal_action_interface.py index d6cf9778..092bd026 100644 --- a/app/internal_action_interface.py +++ b/app/internal_action_interface.py @@ -150,19 +150,30 @@ def describe_screen(cls) -> Dict[str, str]: return {"description": description, "file_path": img_path} @staticmethod - async def do_chat(message: str, platform: str = "CraftBot TUI") -> None: + async def do_chat( + message: str, + platform: str = "CraftBot TUI", + session_id: Optional[str] = None, + ) -> None: """Record an agent-authored chat message to the event stream. Args: message: The message content to record. platform: The platform the message is sent to (default: "CraftBot TUI"). + session_id: Optional task/session ID for multi-task isolation. """ if InternalActionInterface.state_manager is None: raise RuntimeError("InternalActionInterface not initialized with StateManager.") - InternalActionInterface.state_manager.record_agent_message(message, platform=platform) + InternalActionInterface.state_manager.record_agent_message( + message, session_id=session_id, platform=platform + ) @staticmethod - async def do_chat_with_attachment(message: str, file_path: str) -> Dict[str, Any]: + async def do_chat_with_attachment( + message: str, + file_path: str, + session_id: Optional[str] = None, + ) -> Dict[str, Any]: """ Send a chat message with a single attachment to the user. @@ -171,20 +182,28 @@ async def do_chat_with_attachment(message: str, file_path: str) -> Dict[str, Any Args: message: The message content file_path: Path to the file (absolute or relative to workspace) + session_id: Optional task/session ID for multi-task isolation. Returns: Dict with 'success', 'files_sent', and optionally 'errors' """ - return await InternalActionInterface.do_chat_with_attachments(message, [file_path]) + return await InternalActionInterface.do_chat_with_attachments( + message, [file_path], session_id=session_id + ) @staticmethod - async def do_chat_with_attachments(message: str, file_paths: List[str]) -> Dict[str, Any]: + async def do_chat_with_attachments( + message: str, + file_paths: List[str], + session_id: Optional[str] = None, + ) -> Dict[str, Any]: """ Send a chat message with one or more attachments to the user. Args: message: The message content file_paths: List of paths to the files (absolute or relative to workspace) + session_id: Optional task/session ID for multi-task isolation. Returns: Dict with 'success' (bool), 'files_sent' (int), and optionally 'errors' (list of str) @@ -198,7 +217,9 @@ async def do_chat_with_attachments(message: str, file_paths: List[str]) -> Dict[ # Check if UI adapter supports attachments (browser adapter) if ui_adapter and hasattr(ui_adapter, 'send_message_with_attachments'): - return await ui_adapter.send_message_with_attachments(message, file_paths, sender=agent_name) + return await ui_adapter.send_message_with_attachments( + message, file_paths, sender=agent_name, session_id=session_id + ) else: # Fallback: send message with attachment notes for non-browser adapters if InternalActionInterface.state_manager is None: @@ -206,7 +227,8 @@ async def do_chat_with_attachments(message: str, file_paths: List[str]) -> Dict[ attachment_notes = "\n".join([f"[Attachment: {fp}]" for fp in file_paths]) InternalActionInterface.state_manager.record_agent_message( - f"{message}\n\n{attachment_notes}" + f"{message}\n\n{attachment_notes}", + session_id=session_id, ) # For non-browser adapters, we can't verify files exist, so assume success return {"success": True, "files_sent": len(file_paths), "errors": None} diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 35b6bd1f..80d27d0f 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -206,6 +206,7 @@ def _init_storage(self) -> None: timestamp=stored.timestamp, message_id=stored.message_id, attachments=attachments, + task_session_id=stored.task_session_id, )) except Exception: # Storage may not be available, continue without persistence @@ -4089,6 +4090,7 @@ async def send_message_with_attachments( file_paths: list, sender: Optional[str] = None, style: str = "agent", + session_id: Optional[str] = None, ) -> Dict[str, Any]: """ Send a chat message with one or more attachments from the agent. @@ -4101,6 +4103,7 @@ async def send_message_with_attachments( file_paths: List of absolute paths or paths relative to workspace sender: Message sender (default: uses agent name from onboarding) style: Message style (default: "agent") + session_id: Optional task/session ID for multi-task isolation. Returns: Dict with 'success' (bool), 'files_sent' (int), and optionally 'errors' (list of str) @@ -4129,6 +4132,7 @@ async def send_message_with_attachments( content=message, style=style, attachments=attachments, + task_session_id=session_id, ) await self._chat.append_message(chat_message) @@ -4202,6 +4206,7 @@ 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 {}), } for m in self._chat.get_messages() ],