diff --git a/.gitignore b/.gitignore index 4fcf4b1a..656cfe90 100644 --- a/.gitignore +++ b/.gitignore @@ -95,7 +95,3 @@ deploy/.env CLAUDE.md .claude/settings.local.json .claude/plans/ - -# Private internal dirs -.codex/ -openspec/ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index f72742f9..4eed4446 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -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 CHILD_DENIED_TOOLS = Set.of( diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index 840b2fa6..c0b81b10 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -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. *

- * 使用 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 DEPTH = ThreadLocal.withInitial(() -> 0); - private static final ThreadLocal PARENT_CONVERSATION_ID = new ThreadLocal<>(); - private static final ThreadLocal> CHILD_DENIED_TOOLS = new ThreadLocal<>(); + /** + * Snapshot of one delegation layer's state. + */ + private record Frame(String parentConversationId, Set childDeniedTools) {} + + private static final ThreadLocal> 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 childDeniedTools() { - Set 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 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 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(); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 5112b53d..b2c1f589 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -431,7 +431,9 @@ public class ConversationService { return objectMapper.readValue(message.getContentParts(), new TypeReference>() {}); } 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()); } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java index 34477876..237e1126 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/MessageContentPart.java @@ -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; + } } diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 98ecc323..62c2d3e9 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -143,6 +143,12 @@ {{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }} + +

+ + {{ parseErrorText }} +
+
@@ -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 {