fix(delegate): reliability patches for multi-agent delegation

This commit is contained in:
matevip 2026-04-22 05:08:08 +08:00
parent 2b867af959
commit 8762a79ec9
6 changed files with 84 additions and 34 deletions

4
.gitignore vendored
View File

@ -95,7 +95,3 @@ deploy/.env
CLAUDE.md
.claude/settings.local.json
.claude/plans/
# Private internal dirs
.codex/
openspec/

View File

@ -40,7 +40,7 @@ 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 = 300; // 5 分钟
private static final int PARALLEL_TIMEOUT_SECONDS = 60;
/** 子 Agent 禁用的工具:防递归 + 防副作用 */
private static final Set<String> CHILD_DENIED_TOOLS = Set.of(

View File

@ -1,62 +1,65 @@
package vip.mate.tool.builtin;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Set;
/**
* 跟踪 Agent 委派调用的上下文信息防止无限递归并传递父会话信息
* Tracks Agent delegation call context to prevent infinite recursion and carry parent session info.
* <p>
* 使用 ThreadLocal 存储当前线程的委派层级父会话 ID 和子 Agent 禁用工具集
* 每次 {@link DelegateAgentTool} 发起委派时调用 enter()返回后调用 exit()
* Uses a ThreadLocal stack so that nested delegations correctly restore the previous layer's
* parentConversationId and childDeniedTools on exit.
* Each {@link DelegateAgentTool} delegation calls enter() before and exit() after execution.
*
* @author MateClaw Team
*/
public final class DelegationContext {
private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0);
private static final ThreadLocal<String> PARENT_CONVERSATION_ID = new ThreadLocal<>();
private static final ThreadLocal<Set<String>> CHILD_DENIED_TOOLS = new ThreadLocal<>();
/**
* Snapshot of one delegation layer's state.
*/
private record Frame(String parentConversationId, Set<String> childDeniedTools) {}
private static final ThreadLocal<Deque<Frame>> STACK = ThreadLocal.withInitial(ArrayDeque::new);
private DelegationContext() {}
/** 获取当前委派深度0 = 顶层调用) */
/** Current delegation depth (0 = top-level call, not inside any delegation) */
public static int currentDepth() {
return DEPTH.get();
return STACK.get().size();
}
/** 获取父会话 ID用于事件 relay */
/** Parent conversation ID for event relay (from the current frame) */
public static String parentConversationId() {
return PARENT_CONVERSATION_ID.get();
Frame top = STACK.get().peek();
return top != null ? top.parentConversationId : null;
}
/** 获取子 Agent 禁用的工具集 */
/** Denied tools set for the child Agent (from the current frame) */
public static Set<String> childDeniedTools() {
Set<String> denied = CHILD_DENIED_TOOLS.get();
return denied != null ? denied : Set.of();
Frame top = STACK.get().peek();
return top != null && top.childDeniedTools != null ? top.childDeniedTools : Set.of();
}
/** 进入下一层委派(带父会话 ID 和子 Agent 工具限制) */
/** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
public static void enter(String parentConversationId, Set<String> deniedTools) {
DEPTH.set(DEPTH.get() + 1);
PARENT_CONVERSATION_ID.set(parentConversationId);
if (deniedTools != null) {
CHILD_DENIED_TOOLS.set(deniedTools);
}
STACK.get().push(new Frame(parentConversationId, deniedTools));
}
/** 进入下一层委派(兼容旧调用) */
/** Enter the next delegation layer (backward-compatible overload) */
public static void enter() {
enter(null, null);
}
/** 退出当前委派层 */
/** Exit the current delegation layer, restoring the previous layer's context */
public static void exit() {
int current = DEPTH.get();
if (current <= 1) {
DEPTH.remove();
PARENT_CONVERSATION_ID.remove();
CHILD_DENIED_TOOLS.remove();
} else {
DEPTH.set(current - 1);
Deque<Frame> stack = STACK.get();
if (!stack.isEmpty()) {
stack.pop();
}
// Clean up ThreadLocal entirely when the stack is empty to prevent memory leaks
if (stack.isEmpty()) {
STACK.remove();
}
}
}

View File

@ -431,7 +431,9 @@ public class ConversationService {
return objectMapper.readValue(message.getContentParts(), new TypeReference<List<MessageContentPart>>() {});
} catch (Exception e) {
log.warn("Failed to parse content_parts for message {}: {}", message.getId(), e.getMessage());
return List.of();
return List.of(MessageContentPart.parseError(
message.getId() != null ? message.getId().toString() : "unknown",
e.getMessage() != null ? e.getMessage() : "unknown error"));
}
}
@ -449,6 +451,7 @@ public class ConversationService {
switch (part.getType()) {
case "text" -> appendSegment(text, part.getText());
case "thinking", "tool_call" -> { /* skip — frontend reads these from contentParts directly */ }
case "parse_error" -> appendSegment(text, part.getText());
case "file" -> appendSegment(text, "[附件] " + safe(part.getFileName()));
default -> appendSegment(text, part.getText());
}

View File

@ -100,4 +100,15 @@ public class MessageContentPart {
part.setText(jsonPayload);
return part;
}
/**
* Create a parse_error content part to surface content_parts deserialization failures
* instead of silently returning an empty list.
*/
public static MessageContentPart parseError(String messageId, String cause) {
MessageContentPart part = new MessageContentPart();
part.setType("parse_error");
part.setText("[Message content parse failed] Message ID: " + messageId + " | Cause: " + cause);
return part;
}
}

View File

@ -143,6 +143,12 @@
<span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
</div>
<!-- parse_error content block -->
<div v-if="parseErrorText" class="parse-error-card">
<el-icon class="parse-error-card__icon"><WarningFilled /></el-icon>
<span class="parse-error-card__text">{{ parseErrorText }}</span>
</div>
<!-- 错误卡片 -->
<div v-if="status === 'failed'" class="error-card">
<div class="error-card__header">
@ -440,6 +446,12 @@ const displayContent = computed(() => {
return text
})
// --- parse_error detection ---
const parseErrorText = computed(() => {
const errorPart = props.message.contentParts?.find(p => p.type === 'parse_error')
return errorPart?.text || ''
})
const renderedContent = computed(() => {
if (!displayContent.value) return ''
return renderMarkdown(displayContent.value)
@ -1082,6 +1094,31 @@ watch(isGenerating, (generating) => {
to { transform: rotate(360deg); }
}
/* ==================== parse_error card ==================== */
.parse-error-card {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 8px;
border-radius: 8px;
background: color-mix(in srgb, var(--mc-warning, #f59e0b) 8%, var(--mc-bg-elevated, #f8fafc));
border: 1px solid color-mix(in srgb, var(--mc-warning, #f59e0b) 25%, transparent);
font-size: 13px;
line-height: 1.5;
color: var(--mc-text-secondary, #64748b);
}
.parse-error-card__icon {
flex-shrink: 0;
color: var(--mc-warning, #f59e0b);
margin-top: 1px;
}
.parse-error-card__text {
word-break: break-word;
}
/* ==================== 审批面板 ==================== */
/* 极简审批状态(一行式) */
.approval-inline {