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

View File

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

View File

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

View File

@ -100,4 +100,15 @@ public class MessageContentPart {
part.setText(jsonPayload); part.setText(jsonPayload);
return part; 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> <span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
</div> </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 v-if="status === 'failed'" class="error-card">
<div class="error-card__header"> <div class="error-card__header">
@ -440,6 +446,12 @@ const displayContent = computed(() => {
return text 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(() => { const renderedContent = computed(() => {
if (!displayContent.value) return '' if (!displayContent.value) return ''
return renderMarkdown(displayContent.value) return renderMarkdown(displayContent.value)
@ -1082,6 +1094,31 @@ watch(isGenerating, (generating) => {
to { transform: rotate(360deg); } 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 { .approval-inline {