mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +08:00
fix(delegate): fix parallel timeout + add real-time per-child visibility
This commit is contained in:
parent
95dd500a16
commit
2e15369465
@ -40,7 +40,13 @@ public class DelegateAgentTool {
|
|||||||
private static final int MAX_DELEGATION_DEPTH = 3;
|
private static final int MAX_DELEGATION_DEPTH = 3;
|
||||||
private static final int MAX_RESULT_LENGTH = 4000;
|
private static final int MAX_RESULT_LENGTH = 4000;
|
||||||
private static final int MAX_PARALLEL_CHILDREN = 3;
|
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 禁用的工具:防递归 + 防副作用 */
|
/** 子 Agent 禁用的工具:防递归 + 防副作用 */
|
||||||
private static final Set<String> CHILD_DENIED_TOOLS = Set.of(
|
private static final Set<String> CHILD_DENIED_TOOLS = Set.of(
|
||||||
@ -203,6 +209,26 @@ public class DelegateAgentTool {
|
|||||||
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
||||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId),
|
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId),
|
||||||
DELEGATION_EXECUTOR);
|
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);
|
futures.put(p.index, future);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,14 +269,24 @@ public class DelegateAgentTool {
|
|||||||
if (p.stopRelay != null) p.stopRelay.run();
|
if (p.stopRelay != null) p.stopRelay.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 广播 delegation_end
|
// 7. 广播 delegation_end(含每个子任务的摘要,前端可用于展示分项结果)
|
||||||
if (hasParent) {
|
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(
|
streamTracker.broadcastObject(parentConversationId, "delegation_end", Map.of(
|
||||||
"parallel", true,
|
"parallel", true,
|
||||||
"totalDurationMs", totalDurationMs,
|
"totalDurationMs", totalDurationMs,
|
||||||
"success", results.stream().allMatch(r -> r.success),
|
"success", results.stream().allMatch(r -> r.success),
|
||||||
"completedCount", results.stream().filter(r -> r.success).count(),
|
"completedCount", results.stream().filter(r -> r.success).count(),
|
||||||
"totalCount", results.size()));
|
"totalCount", results.size(),
|
||||||
|
"childResults", childResults));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 构建返回结果
|
// 8. 构建返回结果
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
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 { useToolLabel } from '@/composables/useToolLabel'
|
||||||
import type { MessageSegment } from '@/types'
|
import type { MessageSegment } from '@/types'
|
||||||
|
|
||||||
@ -17,7 +17,16 @@ watch(() => props.segment.status, (val) => {
|
|||||||
if (val !== 'running') expanded.value = false
|
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 truncatedArgs = computed(() => {
|
||||||
const args = props.segment.toolArgs || ''
|
const args = props.segment.toolArgs || ''
|
||||||
@ -28,7 +37,9 @@ const truncatedArgs = computed(() => {
|
|||||||
const s = vals.join(', ')
|
const s = vals.join(', ')
|
||||||
return s.length > 80 ? s.slice(0, 80) + '...' : s
|
return s.length > 80 ? s.slice(0, 80) + '...' : s
|
||||||
} catch {
|
} 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>
|
<el-icon v-else :size="13"><CloseBold /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="seg-tool__type-icon">
|
<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>
|
<el-icon v-else :size="12"><Setting /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="seg-tool__name">{{ displayName }}</span>
|
<span class="seg-tool__name">{{ displayName }}</span>
|
||||||
|
|||||||
@ -636,18 +636,76 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
|
|
||||||
stream.on('delegation_progress', (data) => {
|
stream.on('delegation_progress', (data) => {
|
||||||
if (isStaleEvent(data)) return
|
if (isStaleEvent(data)) return
|
||||||
if (currentAssistantId.value && data.originalEvent === 'tool_call_started') {
|
if (!currentAssistantId.value) return
|
||||||
const segs = currentSegments.value
|
const segs = currentSegments.value
|
||||||
// 按 childAgentName 匹配对应的 delegation segment(并行时多个)
|
const childName = data.childAgentName || ''
|
||||||
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) =>
|
const delegSeg = segs.findLast((s: MessageSegment) =>
|
||||||
s.type === 'tool_call' && s.status === 'running' && s.toolName === `→ ${childName}`)
|
s.type === 'tool_call' && s.status === 'running' && s.toolName === `→ ${childName}`)
|
||||||
|| segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
|| 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)
|
if (!delegSeg) return
|
||||||
delegSeg.toolArgs = (delegSeg.toolArgs || '') + '\n [子任务] ' + childData
|
|
||||||
|
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) => {
|
stream.on('delegation_end', (data) => {
|
||||||
@ -655,21 +713,42 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (currentAssistantId.value) {
|
if (currentAssistantId.value) {
|
||||||
const segs = currentSegments.value
|
const segs = currentSegments.value
|
||||||
if (data.parallel) {
|
if (data.parallel) {
|
||||||
// 并行模式:关闭所有 running 的 delegation segments
|
// Parallel mode: use per-child results if available (new backend),
|
||||||
const totalMs = data.totalDurationMs ? Math.round(data.totalDurationMs / 1000) : 0
|
// fall back to aggregate success flag for older backends.
|
||||||
segs.filter((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
if (Array.isArray(data.childResults) && data.childResults.length > 0) {
|
||||||
.forEach((s: MessageSegment) => {
|
for (const cr of data.childResults) {
|
||||||
s.status = 'completed'
|
const agentName = cr.agentName || ''
|
||||||
s.toolName = (s.toolName || '') + (data.success ? ' ✓' : ' ✗')
|
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 {
|
} else {
|
||||||
// 单任务模式
|
// Single-task mode
|
||||||
const delegSeg = segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
const delegSeg = segs.findLast((s: MessageSegment) =>
|
||||||
|
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
|
||||||
if (delegSeg) {
|
if (delegSeg) {
|
||||||
delegSeg.status = 'completed'
|
delegSeg.status = data.success ? 'completed' : 'error'
|
||||||
delegSeg.toolName = (delegSeg.toolName || '') + (data.success ? ' ✓' : ' ✗')
|
delegSeg.toolSuccess = data.success
|
||||||
if (data.durationMs) {
|
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)`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export type SSEEventType =
|
|||||||
| 'delegation_start'
|
| 'delegation_start'
|
||||||
| 'delegation_progress'
|
| 'delegation_progress'
|
||||||
| 'delegation_end'
|
| 'delegation_end'
|
||||||
|
| 'delegation_child_complete'
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
type: SSEEventType
|
type: SSEEventType
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user