fix: 修复前端部分的问题
This commit is contained in:
parent
6de12aa954
commit
897e55e672
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,10 @@ class ParallelStreamManager {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'agent_error':
|
case 'agent_error':
|
||||||
store.errorAgentStream(roomId, data.agent_id || data.agentId, data.error)
|
store.errorAgentStream(roomId, data.agent_id || data.agentId, {
|
||||||
|
message: data.error,
|
||||||
|
agentName: data.agent_name || data.agentName
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'parallel_end':
|
case 'parallel_end':
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ const roomsLoading = ref(false)
|
||||||
const providers = ref([])
|
const providers = ref([])
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const newRoom = ref({ title: '', task: '', max_rounds: 5, agent_ids: [] })
|
const newRoom = ref({ title: '', task: '', max_rounds: 5, agent_ids: [], execution_mode: 'sequential' })
|
||||||
const showAddToRoom = ref(false)
|
const showAddToRoom = ref(false)
|
||||||
|
|
||||||
// ============ Selected room state ============
|
// ============ Selected room state ============
|
||||||
|
|
@ -85,6 +85,25 @@ const canEditRoom = computed(() => room.value?.status !== 'running' && !streamin
|
||||||
const executionMode = computed(() => room.value?.execution_mode || 'sequential')
|
const executionMode = computed(() => room.value?.execution_mode || 'sequential')
|
||||||
const isParallelMode = computed(() => executionMode.value === 'parallel')
|
const isParallelMode = computed(() => executionMode.value === 'parallel')
|
||||||
|
|
||||||
|
// Parallel mode agent states
|
||||||
|
const parallelAgents = computed(() => {
|
||||||
|
const roomData = store.rooms[selectedId.value]
|
||||||
|
if (!roomData || !roomData.agents) return {}
|
||||||
|
return roomData.agents
|
||||||
|
})
|
||||||
|
|
||||||
|
const parallelAgentList = computed(() => Object.values(parallelAgents.value))
|
||||||
|
|
||||||
|
const parallelStats = computed(() => {
|
||||||
|
const agents = parallelAgentList.value
|
||||||
|
return {
|
||||||
|
total: agents.length,
|
||||||
|
completed: agents.filter(a => a.status === 'completed').length,
|
||||||
|
streaming: agents.filter(a => a.status === 'streaming').length,
|
||||||
|
error: agents.filter(a => a.status === 'error').length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Group messages by round number for better visual organization
|
// Group messages by round number for better visual organization
|
||||||
const groupedMessages = computed(() => {
|
const groupedMessages = computed(() => {
|
||||||
const groups = []
|
const groups = []
|
||||||
|
|
@ -228,6 +247,7 @@ async function createRoom() {
|
||||||
title: newRoom.value.title,
|
title: newRoom.value.title,
|
||||||
task: newRoom.value.task,
|
task: newRoom.value.task,
|
||||||
max_rounds: newRoom.value.max_rounds,
|
max_rounds: newRoom.value.max_rounds,
|
||||||
|
execution_mode: newRoom.value.execution_mode,
|
||||||
agents
|
agents
|
||||||
})
|
})
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
|
|
@ -243,7 +263,7 @@ async function createRoom() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetNewRoom() {
|
function resetNewRoom() {
|
||||||
newRoom.value = { title: '', task: '', max_rounds: 5, agent_ids: [] }
|
newRoom.value = { title: '', task: '', max_rounds: 5, agent_ids: [], execution_mode: 'sequential' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAgentInNewRoom(agentId) {
|
function toggleAgentInNewRoom(agentId) {
|
||||||
|
|
@ -275,9 +295,15 @@ async function deleteRoom(id) {
|
||||||
async function selectRoom(id) {
|
async function selectRoom(id) {
|
||||||
if (streaming.value) {
|
if (streaming.value) {
|
||||||
if (abortController) abortController.abort()
|
if (abortController) abortController.abort()
|
||||||
|
if (isParallelMode.value) {
|
||||||
|
parallelStreamManager.cancelRoom(selectedId.value)
|
||||||
|
}
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
}
|
}
|
||||||
|
// Clean up both mode states
|
||||||
streamingMessages.value = {}
|
streamingMessages.value = {}
|
||||||
|
store.cleanupRoom(selectedId.value)
|
||||||
|
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
error.value = ''
|
error.value = ''
|
||||||
editingRoomAgent.value = null
|
editingRoomAgent.value = null
|
||||||
|
|
@ -347,9 +373,15 @@ async function startRoom() {
|
||||||
if (e.name !== 'AbortError') error.value = e.message
|
if (e.name !== 'AbortError') error.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
|
// Reload messages after parallel execution completes
|
||||||
if (selectedId.value) {
|
if (selectedId.value) {
|
||||||
const res = await chatRoomsAPI.get(selectedId.value)
|
const [roomRes, msgRes] = await Promise.all([
|
||||||
room.value = res.data
|
chatRoomsAPI.get(selectedId.value),
|
||||||
|
chatRoomsAPI.getMessages(selectedId.value)
|
||||||
|
])
|
||||||
|
room.value = roomRes.data
|
||||||
|
messages.value = msgRes.data?.messages || []
|
||||||
|
store.cleanupRoom(selectedId.value)
|
||||||
await loadRooms()
|
await loadRooms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -457,11 +489,21 @@ function handleSSEEvent(event, data) {
|
||||||
|
|
||||||
async function stopRoom() {
|
async function stopRoom() {
|
||||||
try {
|
try {
|
||||||
if (abortController) { abortController.abort(); abortController = null }
|
// Cancel sequential mode
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
// Cancel parallel mode
|
||||||
|
if (isParallelMode.value) {
|
||||||
|
parallelStreamManager.cancelRoom(selectedId.value)
|
||||||
|
}
|
||||||
await chatRoomsAPI.stop(selectedId.value)
|
await chatRoomsAPI.stop(selectedId.value)
|
||||||
room.value = { ...room.value, status: 'paused' }
|
room.value = { ...room.value, status: 'paused' }
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingMessages.value = {}
|
streamingMessages.value = {}
|
||||||
|
// Clean up parallel store
|
||||||
|
store.cleanupRoom(selectedId.value)
|
||||||
} catch (e) { console.error('Failed to stop room:', e) }
|
} catch (e) { console.error('Failed to stop room:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -704,12 +746,31 @@ onUnmounted(() => {
|
||||||
<span v-else class="mode-badge sequential">📋 Sequential</span>
|
<span v-else class="mode-badge sequential">📋 Sequential</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canEditRoom" class="mode-selector">
|
<div class="mode-selector">
|
||||||
<select v-model="room.execution_mode" @change="updateExecutionMode" class="mode-select">
|
<select
|
||||||
|
v-model="room.execution_mode"
|
||||||
|
@change="updateExecutionMode"
|
||||||
|
class="mode-select"
|
||||||
|
:disabled="!canEditRoom"
|
||||||
|
>
|
||||||
<option value="sequential">📋 Sequential</option>
|
<option value="sequential">📋 Sequential</option>
|
||||||
<option value="parallel">⚡ Parallel</option>
|
<option value="parallel">⚡ Parallel</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Parallel status indicator -->
|
||||||
|
<div v-if="isParallelMode && streaming && parallelAgentList.length > 0" class="parallel-status">
|
||||||
|
<div class="agent-dots">
|
||||||
|
<span
|
||||||
|
v-for="agent in parallelAgentList"
|
||||||
|
:key="agent.id"
|
||||||
|
class="status-dot-small"
|
||||||
|
:class="agent.status"
|
||||||
|
:style="{ background: agent.message?.sender_color || agent.color || '#2563eb' }"
|
||||||
|
:title="agent.name + ': ' + agent.status"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{{ parallelStats.completed }}/{{ parallelStats.total }}</span>
|
||||||
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button v-if="!streaming && room.status !== 'running'" class="btn-ctrl btn-start" @click="startRoom">
|
<button v-if="!streaming && room.status !== 'running'" class="btn-ctrl btn-start" @click="startRoom">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
|
|
@ -733,7 +794,39 @@ onUnmounted(() => {
|
||||||
:room-id="selectedId"
|
:room-id="selectedId"
|
||||||
mode="parallel"
|
mode="parallel"
|
||||||
/>
|
/>
|
||||||
<!-- Sequential mode or completed messages -->
|
<!-- Parallel mode history messages (card layout) -->
|
||||||
|
<template v-else-if="isParallelMode">
|
||||||
|
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
|
||||||
|
<div v-else-if="messages.length === 0" class="chat-empty"><p>点击「开始」启动多 Agent 对话</p></div>
|
||||||
|
<div v-else class="parallel-history">
|
||||||
|
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx" class="parallel-round">
|
||||||
|
<div class="round-divider">
|
||||||
|
<span class="round-divider-line"></span>
|
||||||
|
<span class="round-divider-label">第 {{ roundMsgs[0].round_number || rIdx + 1 }} 轮</span>
|
||||||
|
<span class="round-divider-line"></span>
|
||||||
|
</div>
|
||||||
|
<div class="parallel-grid">
|
||||||
|
<div
|
||||||
|
v-for="msg in roundMsgs"
|
||||||
|
:key="msg.id"
|
||||||
|
class="parallel-card completed"
|
||||||
|
:style="{ borderColor: msg.sender_color }"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="agent-avatar" :style="{ background: msg.sender_color || '#2563eb' }">
|
||||||
|
{{ (msg.sender_name || '?').charAt(0) }}
|
||||||
|
</span>
|
||||||
|
<span class="agent-name" :style="{ color: msg.sender_color }">{{ msg.sender_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<MessageBubble :message="msg" :deletable="false" :compact="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Sequential mode -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
|
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
|
||||||
<div v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击「开始」启动多 Agent 对话</p></div>
|
<div v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击「开始」启动多 Agent 对话</p></div>
|
||||||
|
|
@ -783,7 +876,19 @@ onUnmounted(() => {
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="fg"><label>标题</label><input v-model="newRoom.title" placeholder="项目架构设计讨论" /></div>
|
<div class="fg"><label>标题</label><input v-model="newRoom.title" placeholder="项目架构设计讨论" /></div>
|
||||||
<div class="fg"><label>任务描述</label><textarea v-model="newRoom.task" rows="3" placeholder="描述需要 Agent 讨论的问题..."></textarea></div>
|
<div class="fg"><label>任务描述</label><textarea v-model="newRoom.task" rows="3" placeholder="描述需要 Agent 讨论的问题..."></textarea></div>
|
||||||
<div class="fg"><label>最大轮次</label><input v-model.number="newRoom.max_rounds" type="number" min="1" max="20" /></div>
|
<div class="fg-row">
|
||||||
|
<div class="fg" style="flex: 1;">
|
||||||
|
<label>最大轮次</label>
|
||||||
|
<input v-model.number="newRoom.max_rounds" type="number" min="1" max="20" />
|
||||||
|
</div>
|
||||||
|
<div class="fg" style="flex: 1;">
|
||||||
|
<label>执行模式</label>
|
||||||
|
<select v-model="newRoom.execution_mode">
|
||||||
|
<option value="sequential">📋 串行 - Agent按顺序发言</option>
|
||||||
|
<option value="parallel">⚡ 并行 - Agent同时发言</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="agents-select">
|
<div class="agents-select">
|
||||||
<label class="agents-select-label">选择 Agent</label>
|
<label class="agents-select-label">选择 Agent</label>
|
||||||
<div v-if="agentPool.length === 0" class="no-agents-hint">请先在左侧 Agent 池中创建 Agent</div>
|
<div v-if="agentPool.length === 0" class="no-agents-hint">请先在左侧 Agent 池中创建 Agent</div>
|
||||||
|
|
@ -977,7 +1082,8 @@ textarea.fi { resize: vertical; min-height: 50px; }
|
||||||
.mode-badge.sequential { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
.mode-badge.sequential { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
||||||
|
|
||||||
.mode-selector { margin-left: auto; }
|
.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; }
|
.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; }
|
.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); } }
|
@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; }
|
.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 styling */
|
||||||
.round-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
.round-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
|
@ -1040,6 +1186,54 @@ textarea.fi { resize: vertical; min-height: 50px; }
|
||||||
white-space: nowrap;
|
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 animation */
|
||||||
.streaming-message {
|
.streaming-message {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ class ChatRoomOrchestrator:
|
||||||
# Yield parallel start event
|
# Yield parallel start event
|
||||||
yield sse_event("parallel_start", {
|
yield sse_event("parallel_start", {
|
||||||
"round": round_num,
|
"round": round_num,
|
||||||
|
"max_rounds": self.max_rounds,
|
||||||
"agents": [{"id": a.id, "name": a.name} for a in agents]
|
"agents": [{"id": a.id, "name": a.name} for a in agents]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -364,8 +365,11 @@ class ChatRoomOrchestrator:
|
||||||
):
|
):
|
||||||
if delta.content:
|
if delta.content:
|
||||||
accumulated_content += 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,
|
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:
|
if delta.is_complete:
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue