mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
fix(delegate): reliability patches for multi-agent delegation
This commit is contained in:
parent
2b867af959
commit
8762a79ec9
4
.gitignore
vendored
4
.gitignore
vendored
@ -95,7 +95,3 @@ deploy/.env
|
||||
CLAUDE.md
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
|
||||
# Private internal dirs
|
||||
.codex/
|
||||
openspec/
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user