diff --git a/agent_core/core/impl/action/manager.py b/agent_core/core/impl/action/manager.py index 75ca3091..54152a26 100644 --- a/agent_core/core/impl/action/manager.py +++ b/agent_core/core/impl/action/manager.py @@ -92,6 +92,33 @@ def __init__( self._on_action_end = on_action_end self._get_parent_id = get_parent_id + def _generate_unique_session_id(self) -> str: + """Generate a unique 6-character session ID. + + Creates a short session ID using the first 6 hex characters of a UUID4. + Checks for duplicates against active task IDs from state_manager. + + Returns: + A unique 6-character hex string session ID. + """ + max_attempts = 100 + for _ in range(max_attempts): + candidate = uuid.uuid4().hex[:6] + + # Check against active task IDs from state manager + try: + main_state = self.state_manager.get_main_state() + existing_ids = set(main_state.active_task_ids) if main_state else set() + except Exception: + existing_ids = set() + + if candidate not in existing_ids: + return candidate + + # Fallback to full UUID hex if somehow all short IDs are taken + logger.warning("Could not generate unique 6-char session ID after 100 attempts, using full UUID") + return uuid.uuid4().hex + # ------------------------------------------------------------------ # Public helpers # ------------------------------------------------------------------ @@ -397,7 +424,7 @@ async def execute_single(action: Action, input_data: Dict, action_session_id: st for action, input_data in actions: if action.name == "task_start": # Generate unique session_id for each task_start to prevent overwriting - action_session_id = str(uuid.uuid4()) + action_session_id = self._generate_unique_session_id() logger.info(f"[PARALLEL] Assigning unique session_id {action_session_id} to task_start") else: action_session_id = session_id diff --git a/app/agent_base.py b/app/agent_base.py index 81b3ab1b..6e1934db 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -269,6 +269,7 @@ def __init__( # ── misc ── self.is_running: bool = True self._interface_mode: str = "tui" # Will be updated in run() based on selected interface + self.ui_controller = None # Set by interface after UIController is created self._extra_system_prompt: str = self._load_extra_system_prompt() # Scheduler for periodic tasks (memory processing, proactive checks, etc.) @@ -1429,6 +1430,39 @@ def _format_sessions_for_routing( return "\n\n".join(sections) + async def _generate_unique_session_id(self) -> str: + """Generate a unique 6-character session ID. + + Creates a short session ID using the first 6 hex characters of a UUID4. + Checks for duplicates against running tasks and queued/active triggers. + + Returns: + A unique 6-character hex string session ID. + """ + max_attempts = 100 # Prevent infinite loop in edge cases + for _ in range(max_attempts): + candidate = uuid.uuid4().hex[:6] + + # Check against running tasks + existing_task_ids = set(self.task_manager.tasks.keys()) + + # Check against queued triggers + queued_triggers = await self.triggers.list_triggers() + queued_session_ids = {t.session_id for t in queued_triggers if t.session_id} + + # Check against active triggers (being processed) + active_session_ids = set(self.triggers._active.keys()) + + # Combine all existing IDs + all_existing_ids = existing_task_ids | queued_session_ids | active_session_ids + + if candidate not in all_existing_ids: + return candidate + + # Fallback to full UUID if somehow all short IDs are taken (extremely unlikely) + logger.warning("Could not generate unique 6-char session ID after 100 attempts, using full UUID") + return uuid.uuid4().hex + async def _route_to_session( self, item_type: str, @@ -1526,6 +1560,49 @@ async def _handle_chat_message(self, payload: Dict): f"[CHAT] Routed message to existing session {matched_session_id} " f"(fired={fired}, reason: {routing_result.get('reason', 'N/A')})" ) + + # Reset task status from "waiting" to "running" when user replies + # Update UI regardless of fire() result - user has replied so we should + # acknowledge it. If fire() failed, the task may be stale but we still + # want to reset the waiting indicator. + 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": matched_session_id, + "status": "running", + }, + ) + ) + + # Check if there are still other tasks waiting + # If not, update global agent state back to working + triggers = await self.triggers.list_triggers() + has_waiting_tasks = any( + getattr(t, 'waiting_for_reply', False) + for t in triggers + if t.session_id != matched_session_id + ) + if not has_waiting_tasks: + self.ui_controller.event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={ + "state": "working", + "status_message": "Agent is working...", + }, + ) + ) + + if not fired: + logger.warning( + f"[CHAT] Trigger not found for session {matched_session_id} - " + "message may not be delivered to task" + ) + # Always trust routing decision - don't create new session return @@ -1560,7 +1637,7 @@ async def _handle_chat_message(self, payload: Dict): "Please perform action that best suit this user chat " f"you just received{platform_hint}: {chat_content}" ), - session_id=str(uuid.uuid4()), # Generate unique session ID + session_id=await self._generate_unique_session_id(), payload=trigger_payload, ), skip_merge=True, diff --git a/app/browser/interface.py b/app/browser/interface.py index 37e5845d..1b89e73d 100644 --- a/app/browser/interface.py +++ b/app/browser/interface.py @@ -46,6 +46,7 @@ def __init__( enable_action_panel=True, # Browser has action panel ) self._controller = UIController(agent, self._config) + agent.ui_controller = self._controller # Back-reference for event emission # Create browser adapter self._adapter = BrowserAdapter(self._controller) diff --git a/app/cli/interface.py b/app/cli/interface.py index 7d7fc098..8af4ce66 100644 --- a/app/cli/interface.py +++ b/app/cli/interface.py @@ -46,6 +46,7 @@ def __init__( enable_action_panel=False, # CLI uses inline action display ) self._controller = UIController(agent, self._config) + agent.ui_controller = self._controller # Back-reference for event emission # Create CLI adapter self._adapter = CLIAdapter(self._controller) diff --git a/app/config/settings.json b/app/config/settings.json index afdf424b..403e9084 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -1,7 +1,6 @@ { "general": { - "agent_name": "CraftBot", - "os_language": "en" + "agent_name": "CraftBot" }, "proactive": { "enabled": false @@ -10,10 +9,10 @@ "enabled": true }, "model": { - "llm_provider": "byteplus", - "vlm_provider": "byteplus", - "llm_model": "kimi-k2-250905", - "vlm_model": "seed-1-6-250915" + "llm_provider": "gemini", + "vlm_provider": "gemini", + "llm_model": null, + "vlm_model": null }, "api_keys": { "openai": "", diff --git a/app/state/state_manager.py b/app/state/state_manager.py index f03a9502..e122a1c0 100644 --- a/app/state/state_manager.py +++ b/app/state/state_manager.py @@ -179,6 +179,8 @@ def reset(self) -> None: STATE.agent_properties: AgentProperties = AgentProperties( current_task_id="", action_count=0 ) + # Reset main state to clear active_task_ids and task_summaries + self._main_state = MainState() if self.event_stream_manager: self.event_stream_manager.clear_all() self.clean_state() diff --git a/app/tui/interface.py b/app/tui/interface.py index 4919257b..f25b85a1 100644 --- a/app/tui/interface.py +++ b/app/tui/interface.py @@ -45,6 +45,7 @@ def __init__( enable_action_panel=True, # TUI has action panel ) self._controller = UIController(agent, self._config) + agent.ui_controller = self._controller # Back-reference for event emission # Create TUI adapter self._adapter = TUIAdapter(self._controller) diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 9d9965af..620d074e 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -233,6 +233,12 @@ def _subscribe_events(self) -> None: self._unsubscribers.append( bus.subscribe(UIEventType.GUI_MODE_CHANGED, self._handle_gui_mode_change) ) + self._unsubscribers.append( + bus.subscribe(UIEventType.WAITING_FOR_USER, self._handle_waiting_for_user) + ) + self._unsubscribers.append( + bus.subscribe(UIEventType.TASK_UPDATE, self._handle_task_update) + ) # Footage events self._unsubscribers.append( @@ -386,6 +392,23 @@ def _handle_gui_mode_change(self, event: UIEvent) -> None: if self.footage_component: self.footage_component.set_visible(event.data.get("gui_mode", False)) + def _handle_waiting_for_user(self, event: UIEvent) -> None: + """Handle waiting for user event - update task status to waiting.""" + task_id = event.data.get("task_id", "") + if task_id and self.action_panel: + asyncio.create_task( + self.action_panel.update_item(task_id, "waiting") + ) + + def _handle_task_update(self, event: UIEvent) -> None: + """Handle task update event - update task status.""" + task_id = event.data.get("task_id", "") + status = event.data.get("status", "running") + if task_id and self.action_panel: + asyncio.create_task( + self.action_panel.update_item(task_id, status) + ) + def _handle_footage_update(self, event: UIEvent) -> None: """Handle footage update event.""" if self.footage_component: 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 602c9128..634343e7 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 @@ -29,11 +29,14 @@ } .pending, -.waiting, .idle { color: var(--color-gray-500); } +.waiting { + color: #3b82f6; +} + /* Spinning animation for loader icon */ .spinning { animation: spin 1s linear infinite; @@ -90,11 +93,14 @@ background: var(--color-error); } -.dot_pending, -.dot_waiting { +.dot_pending { background: var(--color-gray-500); } +.dot_waiting { + 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 db0d665a..27ca201e 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 } from 'lucide-react' +import { CheckCircle, XCircle, Loader, Clock, MessageCircle } from 'lucide-react' import styles from './StatusIndicator.module.css' import type { ActionStatus, AgentState } from '../../types' @@ -55,8 +55,9 @@ export function StatusIndicator({ case 'thinking': case 'working': return - case 'pending': case 'waiting': + return + case 'pending': case 'idle': default: return diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index 57cbeb9c..78e99123 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -25,7 +25,7 @@ export interface ChatMessage { // Action/Task Types // ───────────────────────────────────────────────────────────────────── -export type ActionStatus = 'running' | 'completed' | 'error' | 'pending' | 'cancelled' +export type ActionStatus = 'running' | 'completed' | 'error' | 'pending' | 'cancelled' | 'waiting' export type ItemType = 'task' | 'action' | 'reasoning' export interface ActionItem { diff --git a/app/ui_layer/controller/ui_controller.py b/app/ui_layer/controller/ui_controller.py index 2dc41508..09e4dc05 100644 --- a/app/ui_layer/controller/ui_controller.py +++ b/app/ui_layer/controller/ui_controller.py @@ -237,8 +237,9 @@ async def submit_message(self, message: str, adapter_id: str = "") -> None: return # Not a command - send to agent - # Update state - self._state_store.dispatch("SET_AGENT_STATE", AgentStateType.WORKING.value) + # Note: Task status updates (waiting -> running) are handled in _handle_chat_message + # after routing determines the correct session. We don't update here to avoid + # incorrectly changing status of unrelated tasks. # Emit state change event so adapters can update status immediately self._event_bus.emit( @@ -408,6 +409,43 @@ def _update_state_from_event(self, event: UIEvent) -> None: "SET_GUI_MODE", event.data.get("gui_mode", False) ) + elif event.type == UIEventType.WAITING_FOR_USER: + task_id = event.data.get("task_id", "") + if task_id: + # Update specific task status to "waiting" + self._state_store.dispatch( + "UPDATE_ACTION_ITEM", + { + "id": task_id, + "status": "waiting", + }, + ) + # Update global agent state + self._state_store.dispatch( + "SET_AGENT_STATE", AgentStateType.WAITING_FOR_USER.value + ) + # Emit state change event for status bar + self._event_bus.emit( + UIEvent( + type=UIEventType.AGENT_STATE_CHANGED, + data={ + "state": AgentStateType.WAITING_FOR_USER.value, + "status_message": "Waiting for your response", + }, + ) + ) + + elif event.type == UIEventType.TASK_UPDATE: + task_id = event.data.get("task_id", "") + if task_id: + self._state_store.dispatch( + "UPDATE_ACTION_ITEM", + { + "id": task_id, + "status": event.data.get("status", "running"), + }, + ) + async def _consume_triggers(self) -> None: """Consume triggers and run agent reactions.""" while self._running and self._agent.is_running: diff --git a/app/ui_layer/events/event_types.py b/app/ui_layer/events/event_types.py index 79e38ff5..7dbd9c25 100644 --- a/app/ui_layer/events/event_types.py +++ b/app/ui_layer/events/event_types.py @@ -30,6 +30,7 @@ class UIEventType(Enum): # State events AGENT_STATE_CHANGED = auto() GUI_MODE_CHANGED = auto() + WAITING_FOR_USER = auto() # Footage events (for GUI mode screenshots) FOOTAGE_UPDATE = auto() diff --git a/app/ui_layer/events/transformer.py b/app/ui_layer/events/transformer.py index de555692..e1fd6706 100644 --- a/app/ui_layer/events/transformer.py +++ b/app/ui_layer/events/transformer.py @@ -31,6 +31,7 @@ class EventTransformer: SYSTEM_KINDS = {"system", "system message"} INFO_KINDS = {"info", "note"} REASONING_KINDS = {"agent reasoning", "reasoning"} + WAITING_FOR_USER_KINDS = {"waiting_for_user"} # Actions that should be hidden from the UI (for action_start/action_end events) HIDDEN_ACTIONS = { @@ -105,6 +106,10 @@ def transform( message, event.message, timestamp, task_id ) + # Handle waiting_for_user events + if kind in cls.WAITING_FOR_USER_KINDS or "waiting_for_user" in kind: + return cls._create_waiting_for_user_event(message, timestamp, task_id) + if kind in cls.USER_MESSAGE_KINDS: # Skip - user messages are emitted directly by UIController.submit_message() # to avoid duplicate display @@ -444,6 +449,24 @@ def _create_reasoning_event( task_id=task_id, ) + @classmethod + def _create_waiting_for_user_event( + cls, + message: str, + timestamp: datetime, + task_id: Optional[str], + ) -> UIEvent: + """Create a waiting_for_user event.""" + return UIEvent( + type=UIEventType.WAITING_FOR_USER, + data={ + "task_id": task_id or "", + "message": message, + }, + timestamp=timestamp, + task_id=task_id, + ) + @classmethod def _parse_timestamp(cls, iso_ts: Any) -> datetime: """Parse timestamp from various formats."""