Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion agent_core/core/impl/action/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
79 changes: 78 additions & 1 deletion app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/browser/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/cli/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions app/config/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"general": {
"agent_name": "CraftBot",
"os_language": "en"
"agent_name": "CraftBot"
},
"proactive": {
"enabled": false
Expand All @@ -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": "",
Expand Down
2 changes: 2 additions & 0 deletions app/state/state_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions app/tui/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions app/ui_layer/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -55,8 +55,9 @@ export function StatusIndicator({
case 'thinking':
case 'working':
return <Loader size={iconSize} className={styles.spinning} />
case 'pending':
case 'waiting':
return <MessageCircle size={iconSize} />
case 'pending':
case 'idle':
default:
return <Clock size={iconSize} />
Expand Down
2 changes: 1 addition & 1 deletion app/ui_layer/browser/frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 40 additions & 2 deletions app/ui_layer/controller/ui_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions app/ui_layer/events/event_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading