From 5fc60ec5131ff2a621a2de992558bfb2ed02ba76 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 7 Apr 2026 06:39:46 +0800 Subject: [PATCH] feat(agent): smart truncation, stale stream cleanup, configurable tool timeouts, and new indexes --- .../vip/mate/agent/AgentGraphBuilder.java | 5 +- .../graph/executor/ToolExecutionExecutor.java | 87 +++++++++++++------ .../observation/ObservationProcessor.java | 33 ++++++- .../mate/channel/web/ChatStreamTracker.java | 57 ++++++++++++ .../config/GraphObservationProperties.java | 6 ++ .../mate/config/ToolTimeoutProperties.java | 64 ++++++++++++++ .../java/vip/mate/config/WebMvcConfig.java | 2 +- .../src/main/resources/application.yml | 6 ++ .../src/main/resources/db/schema.sql | 5 ++ 9 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index cbc7e5a9..7ed04175 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -110,6 +110,7 @@ public class AgentGraphBuilder { private final ObjectProvider webClientBuilderProvider; private final ObjectMapper objectMapper; private final GraphObservationProperties graphObservationProperties; + private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties; private final WorkspaceFileService workspaceFileService; private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager; @@ -221,7 +222,7 @@ public class AgentGraphBuilder { try { ChatModel fallbackModel = buildFallbackModel(chatModel); NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel); - ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker); + ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties); PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager); StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager); PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper); @@ -315,7 +316,7 @@ public class AgentGraphBuilder { try { ChatModel fallbackModel = buildFallbackModel(chatModel); NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel); - ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker); + ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties); ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker); ActionNode actionNode = new ActionNode(executor, streamTracker); ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 28730ddf..9da4168a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -53,19 +53,44 @@ public class ToolExecutionExecutor { /** 工具结果最大字符数(防止超长结果膨胀 ToolResponseMessage → 撑爆 LLM 上下文) */ private static final int MAX_TOOL_RESULT_CHARS = 8000; + /** 尾部错误模式检测 */ + private static final java.util.regex.Pattern ERROR_TAIL_PATTERN = java.util.regex.Pattern.compile( + "(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b"); + + /** + * 智能截断工具结果:检测尾部是否含错误信息,动态调整 head/tail 比例。 + * 错误信息在尾部时保留 80% tail,确保 agent 能看到错误原因。 + */ + static String truncateToolResult(String result, int maxChars) { + if (result == null || result.length() <= maxChars) return result; + int rawLen = result.length(); + // 检测尾部 2000 字符是否含错误模式 + String tailRegion = result.substring(Math.max(0, rawLen - 2000)); + double headRatio = ERROR_TAIL_PATTERN.matcher(tailRegion).find() ? 0.2 : 0.4; + int headLen = (int) (maxChars * headRatio); + int tailLen = maxChars - headLen - 80; + if (tailLen <= 0) tailLen = maxChars / 2; + return result.substring(0, headLen) + + "\n\n... [结果已截断,原始 " + rawLen + " 字符,保留首尾关键片段] ...\n\n" + + result.substring(rawLen - tailLen); + } + private final Map toolCallbackMap; private final ToolGuardService toolGuardService; private final ToolGuard toolGuard; // legacy fallback private final ApprovalWorkflowService approvalService; private final ChatStreamTracker streamTracker; + private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties; public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService, ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) { - this.toolCallbackMap = toolSet.callbackByName(); - this.toolGuardService = toolGuardService; - this.toolGuard = null; - this.approvalService = approvalService; - this.streamTracker = streamTracker; + this(toolSet, toolGuardService, null, approvalService, streamTracker, null); + } + + public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService, + ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker, + vip.mate.config.ToolTimeoutProperties toolTimeoutProperties) { + this(toolSet, toolGuardService, null, approvalService, streamTracker, toolTimeoutProperties); } public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuard toolGuard, @@ -75,6 +100,26 @@ public class ToolExecutionExecutor { this.toolGuard = toolGuard; this.approvalService = approvalService; this.streamTracker = streamTracker; + this.toolTimeoutProperties = null; + } + + private ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService, + ToolGuard toolGuard, ApprovalWorkflowService approvalService, + ChatStreamTracker streamTracker, + vip.mate.config.ToolTimeoutProperties toolTimeoutProperties) { + this.toolCallbackMap = toolSet.callbackByName(); + this.toolGuardService = toolGuardService; + this.toolGuard = toolGuard; + this.approvalService = approvalService; + this.streamTracker = streamTracker; + this.toolTimeoutProperties = toolTimeoutProperties; + } + + private long getToolTimeoutMs(String toolName) { + if (toolTimeoutProperties != null) { + return toolTimeoutProperties.getTimeoutSeconds(toolName) * 1000L; + } + return 5 * 60 * 1000L; // default 5 min } /** @@ -211,14 +256,9 @@ public class ToolExecutionExecutor { log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName); String result = callback.call(callArguments); int rawLen = result != null ? result.length() : 0; - if (result != null && result.length() > MAX_TOOL_RESULT_CHARS) { - int headLen = (int) (MAX_TOOL_RESULT_CHARS * 0.4); - int tailLen = MAX_TOOL_RESULT_CHARS - headLen - 80; - result = result.substring(0, headLen) - + "\n\n... [结果已截断,原始 " + rawLen + " 字符] ...\n\n" - + result.substring(rawLen - tailLen); - } - log.info("[ToolExecutor] Pre-approved tool {} returned {} chars", toolName, rawLen); + result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS); + log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen, + result != null && result.length() < rawLen ? " (truncated to " + result.length() + ")" : ""); events.add(GraphEventPublisher.toolComplete(toolName, result, true)); return new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, result != null ? result : ""); @@ -305,7 +345,11 @@ public class ToolExecutionExecutor { // 等待所有并行工具完成,按原始顺序填入结果 for (var entry : futures.entrySet()) { try { - ToolResponseMessage.ToolResponse response = entry.getValue().get(5, TimeUnit.MINUTES); + // 按工具名查找配置的超时时间 + PreparedToolCall matchedPc = batch.stream() + .filter(p -> p.resultIndex == entry.getKey()).findFirst().orElse(null); + long timeoutMs = getToolTimeoutMs(matchedPc != null ? matchedPc.toolCall.name() : null); + ToolResponseMessage.ToolResponse response = entry.getValue().get(timeoutMs, TimeUnit.MILLISECONDS); allResponses.set(entry.getKey(), response); } catch (Exception e) { // 超时或异常 — 填入错误响应 @@ -338,18 +382,9 @@ public class ToolExecutionExecutor { ? pc.arguments.substring(0, 200) + "..." : pc.arguments); String result = pc.callback.call(pc.arguments); int rawLen = result != null ? result.length() : 0; - // 截断过长结果,防止 ToolResponseMessage 撑爆 LLM 上下文 - if (result != null && result.length() > MAX_TOOL_RESULT_CHARS) { - int headLen = (int) (MAX_TOOL_RESULT_CHARS * 0.4); - int tailLen = MAX_TOOL_RESULT_CHARS - headLen - 80; - result = result.substring(0, headLen) - + "\n\n... [结果已截断,原始 " + rawLen + " 字符,保留首尾关键片段] ...\n\n" - + result.substring(rawLen - tailLen); - log.info("[ToolExecutor] Tool {} returned {} chars, truncated to {} chars", - toolName, rawLen, result.length()); - } else { - log.info("[ToolExecutor] Tool {} returned {} chars", toolName, rawLen); - } + result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS); + log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen, + result != null && result.length() < rawLen ? " (truncated to " + result.length() + ")" : ""); events.add(GraphEventPublisher.toolComplete(toolName, result, true)); if (streamTracker != null) { streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java index d4294b7c..ea0d4492 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/observation/ObservationProcessor.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import vip.mate.config.GraphObservationProperties; import java.util.List; +import java.util.regex.Pattern; /** * 观察结果处理器 @@ -46,10 +47,16 @@ public class ObservationProcessor { return String.format("[%s] %s", toolName, trimmed); } + /** 用于检测尾部是否包含错误信息 */ + private static final Pattern ERROR_TAIL_PATTERN = Pattern.compile( + "(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b"); + /** - * 截断大文本,保留首尾关键片段 + * 截断大文本,保留首尾关键片段。 *

- * 保留前 40% 和后 60% 扣除标记长度后的内容。 + * 如果尾部 2000 字符内检测到错误模式(error, exception, traceback 等), + * 自动提升 tail 保留比例(默认从 0.6 → 0.8),确保错误信息不被截掉。 + * 同时保证截断后至少保留 minKeepChars 字符。 * * @param text 原始文本 * @param maxLen 最大允许长度 @@ -60,6 +67,14 @@ public class ObservationProcessor { return text; } + // 最少保留保证 + if (maxLen < properties.getMinKeepChars()) { + maxLen = properties.getMinKeepChars(); + if (text.length() <= maxLen) { + return text; + } + } + int originalLen = text.length(); String marker = String.format(properties.getTruncationMarker(), originalLen); int available = maxLen - marker.length(); @@ -67,13 +82,23 @@ public class ObservationProcessor { return text.substring(0, maxLen); } - int headLen = (int) (available * properties.getHeadRatio()); + // 检测尾部是否含错误信息 → 动态调整 head/tail 比例 + double effectiveHeadRatio = properties.getHeadRatio(); + String tailRegion = text.substring(Math.max(0, originalLen - 2000)); + if (ERROR_TAIL_PATTERN.matcher(tailRegion).find()) { + effectiveHeadRatio = 1.0 - properties.getErrorTailRatio(); // 0.2(保留 80% 给 tail) + log.info("[Observation] Error pattern detected in tail, preserving tail (ratio={})", + properties.getErrorTailRatio()); + } + + int headLen = (int) (available * effectiveHeadRatio); int tailLen = available - headLen; String head = text.substring(0, headLen); String tail = text.substring(originalLen - tailLen); - log.info("[Observation] Truncated from {} to {} chars (limit={})", originalLen, head.length() + tail.length(), maxLen); + log.info("[Observation] Truncated from {} to {} chars (limit={}, headRatio={})", + originalLen, head.length() + tail.length(), maxLen, effectiveHeadRatio); return head + marker + tail; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 5861f260..c6d28658 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -91,6 +91,9 @@ public class ChatStreamTracker { /** 已广播的 pending approval ID 集合(用于幂等去重) */ final java.util.Set broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet(); + /** 创建时间(用于 stale 检测和清理) */ + final long createdAt = System.currentTimeMillis(); + RunState(String conversationId) { this.conversationId = conversationId; } @@ -739,4 +742,58 @@ public class ChatStreamTracker { sb.append("\"}"); return sb.toString(); } + + // ==================== Stale RunState 清理 ==================== + + /** 已完成的 RunState 保留时间(5 分钟) */ + private static final long DONE_RETENTION_MS = 5 * 60 * 1000; + /** RunState 最大存活时间(30 分钟,防止挂起的流永远占内存) */ + private static final long MAX_LIFETIME_MS = 30 * 60 * 1000; + + /** + * 定期清理过期的 RunState,防止内存泄漏。 + * - 已完成超过 5 分钟的 → 移除 + * - 存活超过 30 分钟的(无论是否完成)→ 强制移除 + */ + @org.springframework.scheduling.annotation.Scheduled(fixedRate = 600_000) + public void cleanupStaleRuns() { + long now = System.currentTimeMillis(); + int evicted = 0; + + var iterator = runs.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + RunState state = entry.getValue(); + long age = now - state.createdAt; + + boolean shouldEvict = false; + String reason = null; + + if (state.done && age > DONE_RETENTION_MS) { + shouldEvict = true; + reason = "completed and expired"; + } else if (age > MAX_LIFETIME_MS) { + shouldEvict = true; + reason = "exceeded max lifetime (" + (age / 1000) + "s)"; + } + + if (shouldEvict) { + // 先清理资源再移除 + stopHeartbeat(entry.getKey()); + Disposable d = state.disposable; + if (d != null && !d.isDisposed()) { + d.dispose(); + } + iterator.remove(); + evicted++; + log.warn("[SSE] Evicted stale RunState for conversation={}: {}", + entry.getKey(), reason); + } + } + + if (evicted > 0) { + log.info("[SSE] Cleanup completed: evicted {} stale RunState entries, {} remaining", + evicted, runs.size()); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java b/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java index 3a94e650..f93d85fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/config/GraphObservationProperties.java @@ -29,4 +29,10 @@ public class GraphObservationProperties { /** 截断省略标记(%d 会被替换为原始字符数) */ private String truncationMarker = "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n"; + + /** 检测到尾部错误模式时的 tail 保留比例(默认 0.8,优先保留错误信息) */ + private double errorTailRatio = 0.8; + + /** 截断时最少保留字符数(避免过度截断导致信息完全丢失) */ + private int minKeepChars = 2000; } diff --git a/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java b/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java new file mode 100644 index 00000000..775febed --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java @@ -0,0 +1,64 @@ +package vip.mate.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.HashMap; +import java.util.Map; + +/** + * 工具执行超时配置 + *

+ * 支持三级配置:per-tool → per-category → default。 + * 查找优先级:先精确匹配工具名,再匹配类别,最后用默认值。 + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mate.agent.tool.timeout") +public class ToolTimeoutProperties { + + /** 默认超时(秒) */ + private int defaultTimeoutSeconds = 300; + + /** 按工具类别的超时(秒)。key: shell/web/mcp/file */ + private Map perCategory = new HashMap<>(); + + /** 按工具名的超时(秒)。key: 工具名(如 web_fetch) */ + private Map perTool = new HashMap<>(); + + // 内置类别映射 + private static final Map TOOL_CATEGORY_MAP = Map.of( + "execute_bash", "shell", + "run_command", "shell", + "web_fetch", "web", + "url_fetch", "web", + "write_file", "file", + "edit_file", "file", + "read_file", "file" + ); + + /** + * 获取指定工具的超时时间(秒) + * 查找顺序:per-tool → per-category → default + */ + public int getTimeoutSeconds(String toolName) { + // 1. 精确匹配工具名 + if (toolName != null && perTool.containsKey(toolName)) { + return perTool.get(toolName); + } + // 2. 匹配类别 + if (toolName != null) { + String category = TOOL_CATEGORY_MAP.get(toolName); + // MCP 工具通常以 mcp_ 开头 + if (category == null && toolName.startsWith("mcp_")) { + category = "mcp"; + } + if (category != null && perCategory.containsKey(category)) { + return perCategory.get(category); + } + } + // 3. 默认值 + return defaultTimeoutSeconds; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java index c7d3da82..a92d2a55 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java @@ -11,7 +11,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; * @author MateClaw Team */ @Configuration -@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class}) +@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class}) public class WebMvcConfig implements WebMvcConfigurer { @Override diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 59c61a9f..88cfa9cf 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -99,6 +99,12 @@ mate: min-rounds-for-summarize: 3 head-ratio: 0.4 truncation-marker: "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n" + tool: + timeout: + default-timeout-seconds: 300 + per-category: + shell: 120 + web: 30 conversation: window: # 测试时临时调低:2000 token ≈ 2000 中文字,3 轮对话即可触发压缩 diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index 60681c0a..251c88f0 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -431,3 +431,8 @@ CREATE TABLE IF NOT EXISTS mate_memory_recall ( CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall(agent_id); CREATE INDEX IF NOT EXISTS idx_memory_recall_agent_file ON mate_memory_recall(agent_id, filename); CREATE INDEX IF NOT EXISTS idx_memory_recall_score ON mate_memory_recall(agent_id, score); +CREATE INDEX IF NOT EXISTS idx_memory_recall_candidates ON mate_memory_recall(agent_id, promoted, deleted); + +-- 补充复合索引(高频查询优化) +CREATE INDEX IF NOT EXISTS idx_message_conv_time ON mate_message(conversation_id, create_time); +CREATE INDEX IF NOT EXISTS idx_workspace_file_agent_enabled ON mate_workspace_file(agent_id, enabled);