fix(delegate): fix parallel timeout + add real-time per-child visibility

This commit is contained in:
matevip 2026-04-23 08:09:48 +08:00
parent 95dd500a16
commit 2e15369465
4 changed files with 157 additions and 29 deletions

View File

@ -40,7 +40,13 @@ public class DelegateAgentTool {
private static final int MAX_DELEGATION_DEPTH = 3;
private static final int MAX_RESULT_LENGTH = 4000;
private static final int MAX_PARALLEL_CHILDREN = 3;
private static final int PARALLEL_TIMEOUT_SECONDS = 60;
/**
* Per-child timeout raised from 60 s to 120 s so that slow LLM models
* (kimi-code observed p99 91 s) can complete before the parent gives up.
* The previous 60 s limit was structurally impossible to satisfy once any
* child called an LLM-backed tool.
*/
private static final int PARALLEL_TIMEOUT_SECONDS = 120;
/** 子 Agent 禁用的工具:防递归 + 防副作用 */
private static final Set<String> CHILD_DENIED_TOOLS = Set.of(
@ -203,6 +209,26 @@ public class DelegateAgentTool {
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId),
DELEGATION_EXECUTOR);
// Broadcast per-child completion as soon as each child finishes
// frontend can update that child's status without waiting for all children.
if (hasParent) {
final String parentConvIdFinal = parentConversationId;
future.whenComplete((result, ex) -> {
if (!streamTracker.isRunning(parentConvIdFinal)) return;
ChildResult r = (result != null) ? result
: ChildResult.error(p.index, p.agent.getName(), ex != null ? ex.getMessage() : "Unknown error");
streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", Map.of(
"taskIndex", r.taskIndex,
"childConversationId", p.childConvId,
"childAgentName", r.agentName,
"success", r.success,
"durationMs", r.durationMs,
"resultPreview", r.success ? truncate(r.result, 150)
: (r.error != null ? r.error : "error")));
});
}
futures.put(p.index, future);
}
@ -243,14 +269,24 @@ public class DelegateAgentTool {
if (p.stopRelay != null) p.stopRelay.run();
}
// 7. 广播 delegation_end
// 7. 广播 delegation_end含每个子任务的摘要前端可用于展示分项结果
if (hasParent) {
List<Map<String, Object>> childResults = results.stream().map(r -> {
Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("taskIndex", r.taskIndex);
m.put("agentName", r.agentName);
m.put("success", r.success);
m.put("durationMs", r.durationMs);
if (!r.success && r.error != null) m.put("error", r.error);
return m;
}).toList();
streamTracker.broadcastObject(parentConversationId, "delegation_end", Map.of(
"parallel", true,
"totalDurationMs", totalDurationMs,
"success", results.stream().allMatch(r -> r.success),
"completedCount", results.stream().filter(r -> r.success).count(),
"totalCount", results.size()));
"totalCount", results.size(),
"childResults", childResults));
}
// 8. 构建返回结果

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Loading, Select, CloseBold, ArrowDown, Document, Setting } from '@element-plus/icons-vue'
import { Loading, Select, CloseBold, ArrowDown, Document, Setting, Connection } from '@element-plus/icons-vue'
import { useToolLabel } from '@/composables/useToolLabel'
import type { MessageSegment } from '@/types'
@ -17,7 +17,16 @@ watch(() => props.segment.status, (val) => {
if (val !== 'running') expanded.value = false
})
const displayName = computed(() => getToolLabel(props.segment.toolName || ''))
/** Delegation segments are identified by the → prefix injected in useChat.ts. */
const isDelegation = computed(() => (props.segment.toolName || '').startsWith('→'))
const displayName = computed(() => {
const raw = props.segment.toolName || ''
// Strip the prefix for delegation segments so getToolLabel works cleanly,
// then prepend it back as a visual indicator.
if (isDelegation.value) return `${getToolLabel(raw.slice(1).trim())}`
return getToolLabel(raw)
})
const truncatedArgs = computed(() => {
const args = props.segment.toolArgs || ''
@ -28,7 +37,9 @@ const truncatedArgs = computed(() => {
const s = vals.join(', ')
return s.length > 80 ? s.slice(0, 80) + '...' : s
} catch {
return args.length > 80 ? args.slice(0, 80) + '...' : args
// Multi-line delegation progress show first line only in collapsed view
const firstLine = args.split('\n')[0].trim()
return firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
}
})
@ -56,7 +67,8 @@ const isRunning = computed(() => props.segment.status === 'running')
<el-icon v-else :size="13"><CloseBold /></el-icon>
</span>
<span class="seg-tool__type-icon">
<el-icon v-if="isRead" :size="12"><Document /></el-icon>
<el-icon v-if="isDelegation" :size="12"><Connection /></el-icon>
<el-icon v-else-if="isRead" :size="12"><Document /></el-icon>
<el-icon v-else :size="12"><Setting /></el-icon>
</span>
<span class="seg-tool__name">{{ displayName }}</span>

View File

@ -636,18 +636,76 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('delegation_progress', (data) => {
if (isStaleEvent(data)) return
if (currentAssistantId.value && data.originalEvent === 'tool_call_started') {
const segs = currentSegments.value
// 按 childAgentName 匹配对应的 delegation segment并行时多个
const childName = data.childAgentName || ''
const delegSeg = segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName === `${childName}`)
|| segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
if (delegSeg) {
const childData = typeof data.data === 'string' ? data.data : JSON.stringify(data.data)
delegSeg.toolArgs = (delegSeg.toolArgs || '') + '\n [子任务] ' + childData
if (!currentAssistantId.value) return
const segs = currentSegments.value
const childName = data.childAgentName || ''
// Find the running delegation segment for this child (or fall back to any running delegation)
const delegSeg = segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName === `${childName}`)
|| segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
if (!delegSeg) return
if (data.originalEvent === 'tool_call_started') {
// Child started a sub-tool — append activity hint so the user sees the child is working
const childData = data.data
const toolName = typeof childData === 'object' ? childData?.toolName : String(childData || '')
if (toolName) {
delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n → ${toolName}`
}
} else if (data.originalEvent === 'tool_call_completed') {
// Child finished a sub-tool call — update the running hint
const childData = data.data
const toolName = typeof childData === 'object' ? childData?.toolName : String(childData || '')
const success = typeof childData === 'object' ? childData?.success !== false : true
if (toolName) {
// Replace last appended "→ toolName" with "✓/✗ toolName"
delegSeg.toolArgs = (delegSeg.toolArgs || '').replace(
new RegExp(`\\n → ${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`),
`\n ${success ? '✓' : '✗'} ${toolName}`)
}
} else if (data.originalEvent === 'phase') {
// Child entered a new phase (reasoning, executing_tool, etc.)
const phase = typeof data.data === 'object' ? data.data?.phase : String(data.data || '')
const phaseHints: Record<string, string> = {
reasoning: '…',
executing_tool: '→',
planning: '📋',
summarizing: '✍',
}
const hint = phaseHints[phase]
if (hint && !delegSeg.toolArgs?.endsWith(hint)) {
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ' ' + hint
}
}
flushSegmentsToMessage()
})
// Per-child completion: fires as soon as each individual child agent finishes,
// before the overall delegation_end. Marks that child's segment done immediately
// so the user sees incremental progress rather than a bulk update at the end.
stream.on('delegation_child_complete', (data) => {
if (isStaleEvent(data)) return
if (!currentAssistantId.value) return
const segs = currentSegments.value
const childName = data.childAgentName || ''
const delegSeg = segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName === `${childName}`)
|| segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
if (delegSeg) {
delegSeg.status = data.success ? 'completed' : 'error'
delegSeg.toolSuccess = data.success
// Append duration to args so the user sees how long each child took
if (data.durationMs) {
const durSec = Math.round(data.durationMs / 1000)
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${durSec}s)`
}
if (!data.success && data.resultPreview) {
delegSeg.toolResult = data.resultPreview
}
}
flushSegmentsToMessage()
})
stream.on('delegation_end', (data) => {
@ -655,21 +713,42 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (currentAssistantId.value) {
const segs = currentSegments.value
if (data.parallel) {
// 并行模式:关闭所有 running 的 delegation segments
const totalMs = data.totalDurationMs ? Math.round(data.totalDurationMs / 1000) : 0
segs.filter((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
.forEach((s: MessageSegment) => {
s.status = 'completed'
s.toolName = (s.toolName || '') + (data.success ? ' ✓' : ' ✗')
})
// Parallel mode: use per-child results if available (new backend),
// fall back to aggregate success flag for older backends.
if (Array.isArray(data.childResults) && data.childResults.length > 0) {
for (const cr of data.childResults) {
const agentName = cr.agentName || ''
const seg = segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' &&
(s.status === 'running' || s.status === 'completed') &&
s.toolName?.includes(agentName))
if (seg && seg.status === 'running') {
// Segment not yet closed by delegation_child_complete (e.g. timed out child)
seg.status = cr.success ? 'completed' : 'error'
seg.toolSuccess = cr.success
if (cr.durationMs) {
const durSec = Math.round(cr.durationMs / 1000)
seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${durSec}s)`
}
}
}
} else {
// Legacy fallback: mark all remaining running delegation segments with overall status
segs.filter((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
.forEach((s: MessageSegment) => {
s.status = data.success ? 'completed' : 'error'
})
}
} else {
// 单任务模式
const delegSeg = segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
// Single-task mode
const delegSeg = segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
if (delegSeg) {
delegSeg.status = 'completed'
delegSeg.toolName = (delegSeg.toolName || '') + (data.success ? ' ✓' : ' ✗')
delegSeg.status = data.success ? 'completed' : 'error'
delegSeg.toolSuccess = data.success
if (data.durationMs) {
delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n 耗时: ${Math.round(data.durationMs / 1000)}s`
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${Math.round(data.durationMs / 1000)}s)`
}
}
}

View File

@ -43,6 +43,7 @@ export type SSEEventType =
| 'delegation_start'
| 'delegation_progress'
| 'delegation_end'
| 'delegation_child_complete'
export interface SSEEvent {
type: SSEEventType