请先在左侧 Agent 池中创建 Agent
@@ -977,7 +1082,8 @@ textarea.fi { resize: vertical; min-height: 50px; }
.mode-badge.sequential { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
.mode-selector { margin-left: auto; }
-.mode-select { padding: 0.25rem 0.5rem; font-size: 0.75rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); }
+.mode-select { padding: 0.25rem 0.5rem; font-size: 0.75rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); cursor: pointer; }
+.mode-select:disabled { opacity: 0.7; cursor: not-allowed; background: var(--bg-secondary); }
.toolbar-actions { display: flex; gap: 0.4rem; align-items: center; }
.btn-ctrl { display: flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.75rem; border: none; border-radius: 6px; font-size: 0.8rem; cursor: pointer; font-weight: 500; }
@@ -999,6 +1105,46 @@ textarea.fi { resize: vertical; min-height: 50px; }
@keyframes spin { to { transform: rotate(360deg); } }
.streaming-hint { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; color: var(--text-secondary); font-size: 0.8rem; }
+/* Parallel status indicator */
+.parallel-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 10px;
+ background: rgba(59, 130, 246, 0.1);
+ border-radius: 8px;
+ border: 1px solid rgba(59, 130, 246, 0.2);
+}
+.agent-dots {
+ display: flex;
+ gap: 4px;
+}
+.status-dot-small {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ transition: all 0.3s ease;
+}
+.status-dot-small.streaming {
+ animation: pulse 1.5s ease-in-out infinite;
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
+}
+.status-dot-small.completed {
+ opacity: 1;
+}
+.status-dot-small.error {
+ opacity: 0.6;
+}
+.status-dot-small.pending {
+ opacity: 0.4;
+}
+.progress-text {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #3b82f6;
+ white-space: nowrap;
+}
+
/* Round group styling */
.round-group { display: flex; flex-direction: column; gap: 0.5rem; }
@@ -1040,6 +1186,54 @@ textarea.fi { resize: vertical; min-height: 50px; }
white-space: nowrap;
}
+/* Parallel history messages */
+.parallel-history { padding: 0.5rem 0; }
+.parallel-round { margin-bottom: 1rem; }
+.parallel-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 12px;
+ padding: 0.5rem 0;
+}
+.parallel-card {
+ background: var(--bg-primary);
+ border: 2px solid var(--border-light);
+ border-radius: 12px;
+ overflow: hidden;
+ transition: all 0.2s ease;
+}
+.parallel-card:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+.parallel-card .card-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-light);
+}
+.parallel-card .agent-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 600;
+ font-size: 13px;
+}
+.parallel-card .agent-name {
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+.parallel-card .card-body {
+ padding: 12px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
/* Streaming message animation */
.streaming-message {
opacity: 0.85;
diff --git a/luxx/services/agentic_loop.py b/luxx/services/agentic_loop.py
index e4e02f4..efa9359 100644
--- a/luxx/services/agentic_loop.py
+++ b/luxx/services/agentic_loop.py
@@ -123,14 +123,35 @@ class AgenticLoop:
ctx.tool_calls_list, tool_context or {}
)
- tool_ids = [tc.get("id") for tc in ctx.tool_calls_list]
- tool_step_ids = [
- s.id for s in ctx.all_steps
- if s.type == StepType.TOOL_CALL and s.id_ref in tool_ids
- ]
-
+ # Build mapping from LLM tool_call_id to step id
+ # Use index-based matching as fallback when id is empty
+ tool_call_steps = [s for s in ctx.all_steps if s.type == StepType.TOOL_CALL]
+ logger.debug(f"[EXECUTE_TOOLS] tool_call_steps: {[(s.id, s.name, s.id_ref) for s in tool_call_steps]}")
+ logger.debug(f"[EXECUTE_TOOLS] tool_calls_list: {[(tc.get('id'), tc.get('function', {}).get('name')) for tc in ctx.tool_calls_list]}")
+
for i, (tr, tc) in enumerate(zip(tool_results, ctx.tool_calls_list)):
- ref_id = tool_step_ids[i] if i < len(tool_step_ids) else f"step-{len(ctx.all_steps) - len(tool_results) + i}"
+ # Find the corresponding tool_call step
+ tc_id = tc.get("id", "")
+ ref_id = None
+
+ # First try to match by LLM tool_call_id
+ if tc_id:
+ for s in tool_call_steps:
+ if s.id_ref == tc_id:
+ ref_id = s.id
+ logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}")
+ break
+
+ # Fallback: use index-based matching
+ if ref_id is None and i < len(tool_call_steps):
+ ref_id = tool_call_steps[i].id
+ logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}")
+
+ # Last resort: generate a step id
+ if ref_id is None:
+ ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}"
+ logger.debug(f"[EXECUTE_TOOLS] Generated ref_id: {ref_id}")
+
_, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
events.append(event)
diff --git a/luxx/services/chat_room.py b/luxx/services/chat_room.py
index 15f3719..1b7b9d5 100644
--- a/luxx/services/chat_room.py
+++ b/luxx/services/chat_room.py
@@ -296,6 +296,7 @@ class ChatRoomOrchestrator:
# Yield parallel start event
yield sse_event("parallel_start", {
"round": round_num,
+ "max_rounds": self.max_rounds,
"agents": [{"id": a.id, "name": a.name} for a in agents]
})
@@ -364,8 +365,11 @@ class ChatRoomOrchestrator:
):
if delta.content:
accumulated_content += delta.content
+ # Estimate progress based on content length (assume max ~2000 chars)
+ progress = min(95, int(len(accumulated_content) / 20))
yield {"type": "message_chunk", "id": msg_id, "content": delta.content,
- "accumulated": accumulated_content, "agent_id": agent.id}
+ "accumulated": accumulated_content, "agent_id": agent.id,
+ "progress": progress}
if delta.is_complete:
break
diff --git a/luxx/services/stream_context.py b/luxx/services/stream_context.py
index 1343204..2f35eb2 100644
--- a/luxx/services/stream_context.py
+++ b/luxx/services/stream_context.py
@@ -181,6 +181,14 @@ class StreamState:
"""Finalize the current step and add to all_steps"""
if self.current_step_id is None:
return
+ # TOOL_CALL and TOOL_RESULT steps are handled manually in render methods
+ # They have their own data (name, arguments, id_ref) that can't be auto-generated
+ if self.current_step_type in (StepType.TOOL_CALL, StepType.TOOL_RESULT):
+ # Just reset the state without adding a step
+ self.current_step_id = None
+ self.current_step_idx = None
+ self.current_step_type = None
+ return
if self.current_step_type == StepType.TEXT:
content = self.full_content[self._text_offset:]
elif self.current_step_type == StepType.THINKING:
@@ -208,6 +216,10 @@ class StreamState:
"type": "function",
"function": {"name": "", "arguments": ""}
})
+ else:
+ # Update id if provided (LLM may send id in a later delta)
+ if tc_delta.get("id"):
+ self.tool_calls_list[idx]["id"] = tc_delta["id"]
func = tc_delta.get("function", {})
if func.get("name"):
self.tool_calls_list[idx]["function"]["name"] += func["name"]
@@ -238,31 +250,49 @@ class StreamRenderer:
@staticmethod
def render_tool_calls(state: StreamState) -> List[str]:
- """Render tool calls as SSE events"""
+ """Render tool calls as SSE events
+
+ Note: This manually manages step creation for TOOL_CALL type.
+ The start_step is called to get a unique step id and index,
+ but the step is manually created and added to avoid duplication.
+ """
+ import logging
+ logger = logging.getLogger(__name__)
+
events = []
for tc in state.tool_calls_list:
- # Use start_step to auto-finalize previous and create new step
- state.start_step(StepType.TOOL_CALL)
+ # Get step id and index from start_step
+ step_id = state.start_step(StepType.TOOL_CALL)
step = Step(
- id=state.current_step_id,
+ id=step_id,
index=state.current_step_idx,
type=StepType.TOOL_CALL,
name=tc["function"]["name"],
arguments=tc["function"]["arguments"],
id_ref=tc.get("id", "")
)
- # Append again since start_step finalized previous (if any)
+ # Manually add the step (finalize_step won't add it for TOOL_CALL type)
state.all_steps.append(step)
+ logger.debug(f"[TOOL_CALL] Created step: id={step.id}, index={step.index}, name={step.name}, id_ref={step.id_ref}")
events.append(sse_event("process_step", {"step": step.to_dict()}))
+ # Reset current step state since we manually handled it
+ state.current_step_id = None
+ state.current_step_idx = None
+ state.current_step_type = None
return events
@staticmethod
def render_tool_result(state: StreamState, result: Dict, ref_step_id: str) -> tuple:
- """Render a tool result as SSE event"""
- import json
+ """Render a tool result as SSE event
+
+ Note: This does NOT call start_step because tool_result should be linked
+ to its corresponding tool_call via id_ref, not create a new independent step.
+ The index is derived from the tool_call step to maintain proper ordering.
+ """
+ import json
+ import logging
+ logger = logging.getLogger(__name__)
- # Use start_step to auto-finalize previous and create new step
- state.start_step(StepType.TOOL_RESULT)
content = result.get("content", "")
success = True
@@ -273,9 +303,19 @@ class StreamRenderer:
except (json.JSONDecodeError, TypeError):
pass
+ # Find the corresponding tool_call step to get its index
+ tool_call_step = None
+ for s in state.all_steps:
+ if s.type == StepType.TOOL_CALL and s.id == ref_step_id:
+ tool_call_step = s
+ break
+
+ # Use the same index as the tool_call for proper ordering in frontend
+ step_index = tool_call_step.index if tool_call_step else state.step_index
+
step = Step(
- id=state.current_step_id,
- index=state.current_step_idx,
+ id=f"result-{ref_step_id}", # Unique id based on tool_call step id
+ index=step_index, # Same index as tool_call for proper ordering
type=StepType.TOOL_RESULT,
name=result.get("name", ""),
content=content,
@@ -285,6 +325,8 @@ class StreamRenderer:
state.all_steps.append(step)
state.add_tool_result(result)
+ logger.debug(f"[TOOL_RESULT] Created step: id={step.id}, index={step.index}, id_ref={step.id_ref}, ref_step_id={ref_step_id}")
+
return step, sse_event("process_step", {"step": step.to_dict()})
@staticmethod
diff --git a/luxx/tools/executor.py b/luxx/tools/executor.py
index 2bd3c51..0926dfb 100644
--- a/luxx/tools/executor.py
+++ b/luxx/tools/executor.py
@@ -159,20 +159,26 @@ class ToolExecutor:
tool_calls: List[Dict[str, Any]],
context: Dict[str, Any]
) -> List[Dict[str, Any]]:
- """Process tool calls in parallel"""
+ """Process tool calls in parallel
+
+ IMPORTANT: Results are returned in the SAME ORDER as input tool_calls,
+ not in completion order. This ensures proper matching between tool_call
+ and tool_result steps in the frontend.
+ """
if len(tool_calls) <= 1:
return self.process_tool_calls(tool_calls, context)
tool_ctx = self._build_tool_context(context)
try:
- from concurrent.futures import ThreadPoolExecutor, as_completed
+ from concurrent.futures import ThreadPoolExecutor
- futures = {}
- cached_results = []
+ # Store futures with their original index to maintain order
+ futures_with_index = {}
+ results = [None] * len(tool_calls)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
- for call in tool_calls:
+ for idx, call in enumerate(tool_calls):
call_id = call.get("id", "")
name = call.get("function", {}).get("name", "")
args = self._parse_arguments(call)
@@ -183,24 +189,25 @@ class ToolExecutor:
if cached is not None:
self.history.record(name, args, cached)
- cached_results.append(self._create_tool_result(call_id, name, cached))
+ # Store result at the correct index
+ results[idx] = self._create_tool_result(call_id, name, cached)
else:
- # Submit task
+ # Submit task with index
future = executor.submit(
registry.execute, name, args, context=tool_ctx
)
- futures[future] = (call_id, name, args, cache_key)
+ futures_with_index[future] = (idx, call_id, name, args, cache_key)
- results = list(cached_results)
-
- for future in as_completed(futures):
- call_id, name, args, cache_key = futures[future]
+ # Wait for all futures and store results at correct indices
+ for future in futures_with_index:
+ idx, call_id, name, args, cache_key = futures_with_index[future]
result = future.result()
self.cache.set(cache_key, result)
self.history.record(name, args, result)
- results.append(self._create_tool_result(call_id, name, result))
-
- return results
+ results[idx] = self._create_tool_result(call_id, name, result)
+
+ # Filter out None values (shouldn't happen, but safety check)
+ return [r for r in results if r is not None]
except ImportError:
return self.process_tool_calls(tool_calls, context)