fix: 修复显示部分问题

This commit is contained in:
ViperEkura 2026-04-28 14:23:46 +08:00
parent 6de12aa954
commit 88b77e5567
4 changed files with 113 additions and 34 deletions

View File

@ -112,7 +112,16 @@ const allItems = computed(() => {
} else if (step.type === 'tool_result') { } else if (step.type === 'tool_result') {
// tool_result tool_call // tool_result tool_call
const toolId = step.id_ref || step.id const toolId = step.id_ref || step.id
const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId) // Use find() instead of findLast() for better compatibility
// Search in reverse order to find the most recent matching tool_call
let match = null
for (let j = items.length - 1; j >= 0; j--) {
if (items[j].type === 'tool_call' && items[j].id === toolId) {
match = items[j]
break
}
}
if (match) { if (match) {
let resultContent = step.content || '' let resultContent = step.content || ''
let displayContent = resultContent let displayContent = resultContent

View File

@ -123,14 +123,35 @@ class AgenticLoop:
ctx.tool_calls_list, tool_context or {} ctx.tool_calls_list, tool_context or {}
) )
tool_ids = [tc.get("id") for tc in ctx.tool_calls_list] # Build mapping from LLM tool_call_id to step id
tool_step_ids = [ # Use index-based matching as fallback when id is empty
s.id for s in ctx.all_steps tool_call_steps = [s for s in ctx.all_steps if s.type == StepType.TOOL_CALL]
if s.type == StepType.TOOL_CALL and s.id_ref in tool_ids 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)): 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) _, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
events.append(event) events.append(event)

View File

@ -181,6 +181,14 @@ class StreamState:
"""Finalize the current step and add to all_steps""" """Finalize the current step and add to all_steps"""
if self.current_step_id is None: if self.current_step_id is None:
return 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: if self.current_step_type == StepType.TEXT:
content = self.full_content[self._text_offset:] content = self.full_content[self._text_offset:]
elif self.current_step_type == StepType.THINKING: elif self.current_step_type == StepType.THINKING:
@ -208,6 +216,10 @@ class StreamState:
"type": "function", "type": "function",
"function": {"name": "", "arguments": ""} "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", {}) func = tc_delta.get("function", {})
if func.get("name"): if func.get("name"):
self.tool_calls_list[idx]["function"]["name"] += func["name"] self.tool_calls_list[idx]["function"]["name"] += func["name"]
@ -238,31 +250,49 @@ class StreamRenderer:
@staticmethod @staticmethod
def render_tool_calls(state: StreamState) -> List[str]: 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 = [] events = []
for tc in state.tool_calls_list: for tc in state.tool_calls_list:
# Use start_step to auto-finalize previous and create new step # Get step id and index from start_step
state.start_step(StepType.TOOL_CALL) step_id = state.start_step(StepType.TOOL_CALL)
step = Step( step = Step(
id=state.current_step_id, id=step_id,
index=state.current_step_idx, index=state.current_step_idx,
type=StepType.TOOL_CALL, type=StepType.TOOL_CALL,
name=tc["function"]["name"], name=tc["function"]["name"],
arguments=tc["function"]["arguments"], arguments=tc["function"]["arguments"],
id_ref=tc.get("id", "") 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) 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()})) 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 return events
@staticmethod @staticmethod
def render_tool_result(state: StreamState, result: Dict, ref_step_id: str) -> tuple: def render_tool_result(state: StreamState, result: Dict, ref_step_id: str) -> tuple:
"""Render a tool result as SSE event""" """Render a tool result as SSE event
import json
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", "") content = result.get("content", "")
success = True success = True
@ -273,9 +303,19 @@ class StreamRenderer:
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass 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( step = Step(
id=state.current_step_id, id=f"result-{ref_step_id}", # Unique id based on tool_call step id
index=state.current_step_idx, index=step_index, # Same index as tool_call for proper ordering
type=StepType.TOOL_RESULT, type=StepType.TOOL_RESULT,
name=result.get("name", ""), name=result.get("name", ""),
content=content, content=content,
@ -285,6 +325,8 @@ class StreamRenderer:
state.all_steps.append(step) state.all_steps.append(step)
state.add_tool_result(result) 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()}) return step, sse_event("process_step", {"step": step.to_dict()})
@staticmethod @staticmethod

View File

@ -159,20 +159,26 @@ class ToolExecutor:
tool_calls: List[Dict[str, Any]], tool_calls: List[Dict[str, Any]],
context: Dict[str, Any] context: Dict[str, Any]
) -> List[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: if len(tool_calls) <= 1:
return self.process_tool_calls(tool_calls, context) return self.process_tool_calls(tool_calls, context)
tool_ctx = self._build_tool_context(context) tool_ctx = self._build_tool_context(context)
try: try:
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor
futures = {} # Store futures with their original index to maintain order
cached_results = [] futures_with_index = {}
results = [None] * len(tool_calls)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor: 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", "") call_id = call.get("id", "")
name = call.get("function", {}).get("name", "") name = call.get("function", {}).get("name", "")
args = self._parse_arguments(call) args = self._parse_arguments(call)
@ -183,24 +189,25 @@ class ToolExecutor:
if cached is not None: if cached is not None:
self.history.record(name, args, cached) 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: else:
# Submit task # Submit task with index
future = executor.submit( future = executor.submit(
registry.execute, name, args, context=tool_ctx 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) # Wait for all futures and store results at correct indices
for future in futures_with_index:
for future in as_completed(futures): idx, call_id, name, args, cache_key = futures_with_index[future]
call_id, name, args, cache_key = futures[future]
result = future.result() result = future.result()
self.cache.set(cache_key, result) self.cache.set(cache_key, result)
self.history.record(name, args, result) self.history.record(name, args, result)
results.append(self._create_tool_result(call_id, name, result)) results[idx] = self._create_tool_result(call_id, name, result)
return results # Filter out None values (shouldn't happen, but safety check)
return [r for r in results if r is not None]
except ImportError: except ImportError:
return self.process_tool_calls(tool_calls, context) return self.process_tool_calls(tool_calls, context)