diff --git a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java index 6a98706c..db5165f9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java @@ -52,8 +52,23 @@ public final class GraphEventPublisher { } public static GraphEvent toolStart(String toolName, String arguments) { + return toolStart(null, toolName, arguments); + } + + /** + * Emit a tool_call_started event with the LLM-provided tool_call.id so the + * frontend can match start/complete pairs precisely. Without the id, the + * UI uses toolName + status="running" + findLast() to pair completes back + * to the original card; when the LLM fires multiple calls of the same tool + * (e.g. several execute_shell_command in a row) the matching collapses to + * "the most recent running" and earlier cards get stranded with a + * permanent spinner. Pass the id whenever it's available; null is OK for + * legacy callers. + */ + public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) { long ts = System.currentTimeMillis(); return new GraphEvent(EVENT_TOOL_START, Map.of( + "toolCallId", toolCallId != null ? toolCallId : "", "toolName", toolName, "arguments", arguments != null ? arguments : "", "timestamp", ts @@ -61,8 +76,13 @@ public final class GraphEventPublisher { } public static GraphEvent toolComplete(String toolName, String result, boolean success) { + return toolComplete(null, toolName, result, success); + } + + public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) { long ts = System.currentTimeMillis(); return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of( + "toolCallId", toolCallId != null ? toolCallId : "", "toolName", toolName, "result", result != null ? truncateResult(result) : "", "success", success, 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 c3a7a15a..869bd574 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 @@ -232,7 +232,7 @@ public class ToolExecutionExecutor { String toolName = toolCall.name(); String arguments = toolCall.arguments(); - events.add(GraphEventPublisher.toolStart(toolName, arguments)); + events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments)); // 0. 子会话工具拦截:委派上下文中的子 Agent 禁止调用特定工具 if (vip.mate.tool.builtin.DelegationContext.currentDepth() > 0) { @@ -240,7 +240,7 @@ public class ToolExecutionExecutor { if (denied.contains(toolName)) { String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName; log.info("[ToolExecutor] Child agent blocked from using tool: {}", toolName); - events.add(GraphEventPublisher.toolComplete(toolName, msg, false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse( toolCall.id(), toolName, msg)); continue; @@ -255,7 +255,7 @@ public class ToolExecutionExecutor { log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}", toolName, arguments.length(), jsonEx.getMessage()); String truncationError = normalizeToolExecutionError(jsonEx); - events.add(GraphEventPublisher.toolComplete(toolName, truncationError, false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false)); allResponses.add(new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, truncationError)); continue; @@ -300,7 +300,7 @@ public class ToolExecutionExecutor { ToolCallback callback = toolCallbackMap.get(toolName); if (callback == null) { log.warn("[ToolExecutor] Tool not found: {}", toolName); - events.add(GraphEventPublisher.toolComplete(toolName, "Tool not found: " + toolName, false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false)); allResponses.add(new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, "Tool not found: " + toolName)); continue; @@ -376,7 +376,7 @@ public class ToolExecutionExecutor { ToolCallback callback = toolCallbackMap.get(toolName); if (callback == null) { log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName); - events.add(GraphEventPublisher.toolComplete(toolName, "Tool not found: " + toolName, false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false)); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "Tool not found: " + toolName); } @@ -414,7 +414,7 @@ public class ToolExecutionExecutor { result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS); log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen, result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : ""); - events.add(GraphEventPublisher.toolComplete(toolName, result, true)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true)); return new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, result != null ? result : ""); } catch (Exception e) { @@ -422,7 +422,7 @@ public class ToolExecutionExecutor { String safeError = isReturnDirect(callback) ? "Tool execution failed (details withheld per returnDirect policy)" : "Tool execution failed: " + e.getMessage(); - events.add(GraphEventPublisher.toolComplete(toolName, safeError, false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false)); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError); } } @@ -559,7 +559,7 @@ public class ToolExecutionExecutor { if (streamTracker != null) { streamTracker.updateRunningTool(pc.conversationId, toolName); streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START, - GraphEventPublisher.toolStart(toolName, pc.arguments).data()); + GraphEventPublisher.toolStart(pc.toolCall.id(), toolName, pc.arguments).data()); } log.info("[ToolExecutor] Executing tool: {} with args: {}", toolName, pc.arguments != null && pc.arguments.length() > 200 @@ -611,10 +611,10 @@ public class ToolExecutionExecutor { result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS); log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen, result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : ""); - events.add(GraphEventPublisher.toolComplete(toolName, result, true)); + events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true)); if (streamTracker != null) { streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE, - GraphEventPublisher.toolComplete(toolName, result, true).data()); + GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data()); streamTracker.updateRunningTool(pc.conversationId, null); } return new ToolResponseMessage.ToolResponse( @@ -629,10 +629,10 @@ public class ToolExecutionExecutor { String reportedError = isReturnDirect(pc.callback) ? "Tool execution failed (details withheld per returnDirect policy)" : normalizeToolExecutionError(e); - events.add(GraphEventPublisher.toolComplete(toolName, reportedError, false)); + events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false)); if (streamTracker != null) { streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE, - GraphEventPublisher.toolComplete(toolName, reportedError, false).data()); + GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false).data()); streamTracker.updateRunningTool(pc.conversationId, null); } return new ToolResponseMessage.ToolResponse( @@ -653,7 +653,7 @@ public class ToolExecutionExecutor { if (evaluation.shouldBlock()) { log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary()); - events.add(GraphEventPublisher.toolComplete(toolName, evaluation.summary(), false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, evaluation.summary(), false)); return GuardDecision.blocked( "[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。"); } @@ -672,7 +672,7 @@ public class ToolExecutionExecutor { if (guardResult.isBlocked()) { log.warn("[ToolExecutor] Tool call BLOCKED by ToolGuard: tool={}, reason={}", toolName, guardResult.reason()); - events.add(GraphEventPublisher.toolComplete(toolName, guardResult.reason(), false)); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, guardResult.reason(), false)); return GuardDecision.blocked( "[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。"); } diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java index 702eb67c..2d08dcc8 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalController.java @@ -72,6 +72,14 @@ public class ApprovalController { log.info("[Approval] User {} {} pending {} for conversation {}", username, decision, request.getPendingId(), conversationId); + // Persist the resolved status onto the assistant message metadata so a + // subsequent page refresh doesn't hydrate a ghost approval banner from + // the stale "pending_approval" status frozen at message-save time. + conversationService.markPendingApprovalsResolved( + conversationId, + java.util.Set.of(request.getPendingId()), + "approved".equalsIgnoreCase(decision) ? "approved" : "denied"); + // Web 端的 replay 由前端发送 /approve 消息到 POST /stream 触发(ChatController 拦截) // 此端点只更新审批状态,保留给 IM 渠道(DingTalk/Feishu 等通过 ChannelMessageRouter 调用) diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java index 8a05c5b8..0a46420c 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalService.java @@ -120,6 +120,37 @@ public class ApprovalService { log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId); } + /** + * Bulk-deny every pending approval still in {@code pending} status for this + * conversation. Used by the Stop endpoint to clear orphaned approvals so + * subsequent UI refreshes don't keep popping the "approve write_file?" + * banner forever, and so a `findPendingByConversation` lookup right after + * Stop returns null. + *

+ * Returns the list of {@link PendingApproval} records that were marked + * denied — callers typically use this list to update the corresponding + * {@code mate_message.metadata.pendingApproval.status} entries in DB + * (otherwise a page refresh re-hydrates ghost approvals from message + * metadata even after the in-memory map is cleared). + */ + public List denyAllByConversation(String conversationId, String userId) { + Instant now = Instant.now(); + List resolved = new ArrayList<>(); + for (PendingApproval pending : pendingMap.values()) { + if (!conversationId.equals(pending.getConversationId())) continue; + if (!"pending".equals(pending.getStatus())) continue; + pending.setStatus("denied"); + pending.setResolvedAt(now); + pending.setResolvedBy(userId); + resolved.add(pending); + } + if (!resolved.isEmpty()) { + log.info("[Approval] Bulk-denied {} pending approvals for conversation {}", + resolved.size(), conversationId); + } + return resolved; + } + // ==================== 查询 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 3742fd18..e934ce85 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -194,6 +194,11 @@ public class ChatController { if (isDenyCommand) { approvalService.resolve(pending.getPendingId(), username, "denied"); conversationService.removeApprovalPlaceholders(conversationId); + // Sync the persisted message metadata so a subsequent page refresh + // doesn't re-hydrate a "pending_approval" ghost from message metadata + // that the in-memory map already moved past. + conversationService.markPendingApprovalsResolved(conversationId, + java.util.Set.of(pending.getPendingId()), "denied"); log.info("[Approval-Stream] User {} denied pending {} for conversation {}", username, pending.getPendingId(), conversationId); } @@ -212,6 +217,11 @@ public class ChatController { } // 清理 DB 中残留的审批占位消息(对齐 IM 渠道 replayApprovedToolCall) conversationService.removeApprovalPlaceholders(conversationId); + // Same metadata sync as the deny branch — without this, refresh + // after approval still shows the spinner-state approval card + // because the persisted message says status='pending_approval'. + conversationService.markPendingApprovalsResolved(conversationId, + java.util.Set.of(consumed.getPendingId()), "approved"); log.info("[Approval-Stream] User {} approved pending {} for conversation {}", username, consumed.getPendingId(), conversationId); } @@ -762,18 +772,50 @@ public class ChatController { /** * 停止指定会话的流式生成。 * 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),已生成的部分内容以 stopped 状态入库。 + *

+ * Stop 同时清理所有未 resolve 的 pending approval:当 LLM 在一个 turn 里连发了 + * 多个需要审批的工具调用、用户在中间 Stop 时,这些 pending 会一直留在 in-memory + * pendingMap 里。下次刷新页面时 frontend 的 hydrate 链路(`getPendingApprovals` API + * + 消息 metadata 里的 `pendingApproval` 字段)会反复弹出"允许 xxx 执行?"banner。 + * Stop 端点现在 deny 所有 pending、同步 update 受影响 message 的 metadata,并广播 + * tool_approval_resolved 让前端实时清理 UI。 */ @Operation(summary = "停止流式生成") @PostMapping("/{conversationId}/stop") - public R> stopStream(@PathVariable String conversationId, Authentication auth) { + public R> stopStream(@PathVariable String conversationId, Authentication auth) { String username = auth != null ? auth.getName() : "anonymous"; // 权限校验:已认证用户需验证会话归属,匿名用户(permitAll)直接放行 if (auth != null && !conversationService.isConversationOwner(conversationId, username)) { return R.fail("无权操作该会话"); } boolean stopped = streamTracker.requestStop(conversationId); - log.info("Stop requested: conversationId={}, user={}, stopped={}", conversationId, username, stopped); - return R.ok(Map.of("stopped", stopped)); + + // Sweep ghost approvals — see method-level Javadoc for rationale. + java.util.List denied = + approvalService.denyAllByConversation(conversationId, username); + int messagesRewritten = 0; + if (!denied.isEmpty()) { + java.util.Set ids = denied.stream() + .map(vip.mate.approval.PendingApproval::getPendingId) + .collect(java.util.stream.Collectors.toSet()); + messagesRewritten = conversationService.markPendingApprovalsResolved(conversationId, ids, "denied"); + for (vip.mate.approval.PendingApproval p : denied) { + broadcastEvent(conversationId, "tool_approval_resolved", Map.of( + "pendingId", p.getPendingId(), + "decision", "denied", + "toolName", p.getToolName() != null ? p.getToolName() : "", + "timestamp", System.currentTimeMillis() + )); + } + } + + log.info("Stop requested: conversationId={}, user={}, stopped={}, ghostPendingsCleared={}, messagesRewritten={}", + conversationId, username, stopped, denied.size(), messagesRewritten); + return R.ok(Map.of( + "stopped", stopped, + "ghostPendingsCleared", denied.size(), + "messagesRewritten", messagesRewritten + )); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java b/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java index b4b2d723..cc74d300 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/config/DatabaseBootstrapRunner.java @@ -68,9 +68,9 @@ public class DatabaseBootstrapRunner implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { - // Schema creation is now handled by Flyway (db/migration/) - runToolSyncScript(); - + // Schema creation and built-in tool registration are handled by + // Flyway (db/migration/). New built-in tools must ship as their own + // Vxx__register__tool.sql migration (see V3, V31 for examples). if (isDataAlreadySeeded()) { initialized = true; log.info("Database already initialized, skipping seed data"); @@ -168,12 +168,6 @@ public class DatabaseBootstrapRunner implements ApplicationRunner { return isMySQL; } - private void runToolSyncScript() { - String script = isMySQL() ? "db/tools-sync-mysql.sql" : "db/tools-sync.sql"; - runScript(script); - log.info("Tool sync completed ({})", script); - } - private void runScript(String path) { ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); populator.setContinueOnError(false); diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java index 79167804..9644f675 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package vip.mate.config; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; @@ -19,6 +20,7 @@ import vip.mate.channel.web.TalkModeWebSocketHandler; @Configuration @EnableWebSocket @RequiredArgsConstructor +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) public class WebSocketConfig implements WebSocketConfigurer { /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index 9c799d84..2f24aed5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -152,14 +152,32 @@ public class ShellExecuteTool { } /** - * 将命令中的嵌入换行符替换为空格。 - * LLM 在 JSON tool_call 中产生的 \n 解码后变成真实换行, - * 在 Windows cmd.exe 中会导致命令被截断,在 Unix sh 中可能被误解为命令分隔符。 + * Collapse embedded newlines for Windows cmd.exe (where they break parsing), + * but **leave them alone on Unix**. + *

+ * The original implementation collapsed on every platform under the worry + * that a stray newline could be misread as a command separator on POSIX + * shells. In practice that worry is wrong for two common idioms the LLM + * actually uses to write files: heredocs (`cat <<EOF\nbody\nEOF`) and + * `python <<EOF` invocations. Both depend on real line breaks to + * delimit the body from the closing tag — collapsing newlines turns + * `cat <<EOF\nbody\nEOF` into `cat <<EOF body EOF`, which the + * shell reads as "open heredoc, immediately close, write 0 bytes." The + * symptom: every chapter file produced by the agent ends up 0-byte. + *

+ * Unix shell already separates commands with `;` or `&&`, not + * unquoted newlines, so leaving newlines in is actually safer — and + * heredocs / multi-line commands now behave as the LLM expects. Windows + * cmd.exe still gets the collapse because there it really does break. */ private static String collapseEmbeddedNewlines(String command) { if (command == null || !command.contains("\n")) { return command; } + if (!IS_WINDOWS) { + // POSIX shell handles newlines correctly within heredocs / scripts + return command; + } return command.replace("\r\n", " ").replace("\n", " "); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java index 68f3226b..38b5290d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java @@ -1,20 +1,25 @@ package vip.mate.tool.guard.guardian; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; import vip.mate.tool.guard.model.*; import java.util.List; import java.util.Set; /** - * 文件写入守卫 - *

- * 标记写文件/编辑文件操作为 MEDIUM 风险。 - * 最终是否需要审批由 ToolPolicyResolver 决定。 + * File-write guardian — historically marked every write_file / edit_file call + * as MEDIUM risk and forced an approval popup. Disabled (no @Component) + * because in-workspace writes are already path-bounded by + * {@code FilePathGuardian} + {@code WorkspacePathGuard.validatePath()}, and + * the per-call approval prompt drove operators to give up on multi-file + * workflows (a 22-chapter docx generation = 22 popups). The class is kept on + * disk for two reasons: (1) re-enabling guardian-level write approval is a + * one-line `@Component` change if a deployment really wants it, (2) it + * documents the historical behavior for anyone diffing why approval suddenly + * stopped firing on write_file. The mate_tool_guard_config row's + * guarded_tools_json was narrowed to {@code execute_shell_command} in V51. */ @Slf4j -@Component public class FileWriteGuardian implements ToolGuardGuardian { private static final Set FILE_WRITE_TOOL_NAMES = Set.of( 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 af23115d..5030ef0c 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 @@ -513,6 +513,67 @@ public class ConversationService { *

* 在 replay 前调用,确保 LLM 上下文中不包含任何审批相关文本。 */ + /** + * Update the {@code metadata.pendingApproval.status} field on every assistant + * message in this conversation whose embedded pendingId matches one in + * {@code resolvedPendingIds}. Run after {@code ApprovalService.resolve()} or + * {@code denyAllByConversation()} to keep the persisted message metadata in + * sync with the in-memory pendingMap — otherwise a page refresh hydrates the + * stale {@code pending_approval} status from message metadata and the UI + * pops a ghost approval banner for an approval the user already settled. + *

+ * Idempotent: messages without a matching pendingApproval, or whose status + * was already moved off {@code pending_approval}, are left untouched. + * + * @param conversationId target conversation + * @param resolvedPendingIds pendingIds whose owning message metadata should + * flip {@code pendingApproval.status} to {@code denied} + * @return how many messages were rewritten + */ + @Transactional + public int markPendingApprovalsResolved(String conversationId, + java.util.Set resolvedPendingIds, + String newStatus) { + if (conversationId == null || resolvedPendingIds == null || resolvedPendingIds.isEmpty()) { + return 0; + } + String targetStatus = (newStatus == null || newStatus.isBlank()) ? "denied" : newStatus; + List messages = listMessages(conversationId); + int rewritten = 0; + for (MessageEntity msg : messages) { + if (!"assistant".equals(msg.getRole())) continue; + String raw = msg.getMetadata(); + if (raw == null || raw.isBlank() || !raw.contains("pendingApproval")) continue; + + try { + java.util.Map meta = objectMapper.readValue(raw, new TypeReference<>() {}); + Object pa = meta.get("pendingApproval"); + if (!(pa instanceof java.util.Map)) continue; + @SuppressWarnings("unchecked") + java.util.Map pendingApproval = (java.util.Map) pa; + Object pid = pendingApproval.get("pendingId"); + if (pid == null || !resolvedPendingIds.contains(String.valueOf(pid))) continue; + Object status = pendingApproval.get("status"); + if (!"pending_approval".equals(String.valueOf(status))) continue; + + pendingApproval.put("status", targetStatus); + meta.put("pendingApproval", pendingApproval); + msg.setMetadata(objectMapper.writeValueAsString(meta)); + messageMapper.updateById(msg); + rewritten++; + } catch (Exception e) { + log.warn("[ConversationService] Failed to rewrite pendingApproval status for message {}: {}", + msg.getId(), e.getMessage()); + } + } + if (rewritten > 0) { + log.info("[ConversationService] Rewrote pendingApproval.status={} on {} message(s) " + + "in conversation {} (cleared {} ghost pendings)", + targetStatus, rewritten, conversationId, resolvedPendingIds.size()); + } + return rewritten; + } + @Transactional public void removeApprovalPlaceholders(String conversationId) { List messages = listMessages(conversationId); diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 656f7605..6046b6c6 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -1916,7 +1916,7 @@ SELECT 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index e820d4c1..eb14204d 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -1955,7 +1955,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 494fcd77..c4df6db2 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -1723,7 +1723,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1870,7 +1870,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1956,7 +1956,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 17524c8a..1c28aa2e 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -1687,7 +1687,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1834,7 +1834,7 @@ VALUES ( ## 边界 - 私密的保持私密。 -- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 +- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。 - 拿不准就先问。 ## 风格 @@ -1920,7 +1920,7 @@ SELECT 1000000001, TRUE, 'all', - '["write_file","edit_file","execute_shell_command"]', + '["execute_shell_command"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V48__agent_max_iterations_and_agents_md_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V48__agent_max_iterations_and_agents_md_tools.sql new file mode 100644 index 00000000..1e045cf5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V48__agent_max_iterations_and_agents_md_tools.sql @@ -0,0 +1,35 @@ +-- V48: Two unrelated agent runtime fixes that surfaced in the same dogfood session. +-- +-- 1) max_iterations: V47 only matched rows still holding the original seed (25 / 20). +-- User-customized rows (any value other than 25/20) were silently skipped, so the +-- StateGraph ReAct agent kept running with maxIterations=25 even after V47. +-- V48 widens the condition: any seeded agent row with max_iterations < 100 gets +-- bumped to 100, matching the QwenPaw-style ceiling. Custom rows above 100 still +-- get clamped at runtime by AgentGraphBuilder. +-- +-- 2) AGENTS.md tool guidance: the workspace's seeded AGENTS.md only mentioned +-- `execute_shell_command` and `read_file`, leading the LLM to write document +-- chapters by piping printf / heredoc through the shell (which silently folds +-- multi-line strings into a single line on this host) instead of using the +-- `write_file` tool that's actually registered. After 25+ iterations of failed +-- shell tricks the agent ran out of budget without ever calling renderDocxFromFiles. +-- Replace the relevant line so write_file / edit_file are surfaced. Other +-- workspace files (SOUL.md, MEMORY.md, etc.) are untouched. + +UPDATE mate_agent SET max_iterations = 100 +WHERE id IN (1000000001, 1000000002, 1000000003) + AND (max_iterations IS NULL OR max_iterations < 100); + +UPDATE mate_workspace_file +SET content = REPLACE( + content, + '需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。', + '需要执行文件操作或命令时,直接调用对应的工具:' || CHAR(10) + || '- 读文件 → `read_file`' || CHAR(10) + || '- 写新文件或覆盖整个文件 → `write_file`(一次写完整内容,不要用 printf / heredoc / echo 拼)' || CHAR(10) + || '- 修改已有文件局部内容 → `edit_file`' || CHAR(10) + || '- 执行 shell 命令 → `execute_shell_command`' || CHAR(10) + || '不要用文本描述你要做什么。' + ) +WHERE filename = 'AGENTS.md' + AND content LIKE '%execute_shell_command、read_file%'; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V49__reenable_write_edit_file_tools.sql b/mateclaw-server/src/main/resources/db/migration/h2/V49__reenable_write_edit_file_tools.sql new file mode 100644 index 00000000..23ae7664 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V49__reenable_write_edit_file_tools.sql @@ -0,0 +1,24 @@ +-- V49: Re-enable WriteFileTool and EditFileTool. +-- +-- These two tool beans were observed disabled in production +-- (mate_tool.enabled = FALSE for bean_name in writeFileTool / editFileTool), +-- which makes ToolRegistry skip them during agent toolset construction. +-- The LLM still sees a tool named `write_file` from some other surface +-- (likely a name collision through Spring AI's tool discovery from a +-- non-disabled bean's @Tool annotation), and ToolGuard accepts the call — +-- so the request reaches approval. But after approval, the replay path +-- looks the callback up in toolCallbackMap and gets nothing, returning +-- "Tool not found: write_file" to the LLM. The LLM then falls back to +-- assembling files via execute_shell_command + printf / heredoc, which +-- silently collapses multi-line content on this host and burns the +-- iteration budget without producing usable output. +-- +-- The right fix is to make sure the tool the LLM sees is the one the +-- executor can run. Re-enable both rows; if a future install really wants +-- write/edit disabled, the user can flip the toggle in the admin UI again. +-- +-- Idempotent: only flips rows currently disabled. + +UPDATE mate_tool SET enabled = TRUE +WHERE bean_name IN ('writeFileTool', 'editFileTool') + AND enabled = FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V50__fix_agents_md_tool_guidance_concat.sql b/mateclaw-server/src/main/resources/db/migration/h2/V50__fix_agents_md_tool_guidance_concat.sql new file mode 100644 index 00000000..5117edbe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V50__fix_agents_md_tool_guidance_concat.sql @@ -0,0 +1,64 @@ +-- V50: Fix V48's broken AGENTS.md UPDATE on H2. +-- +-- V48 used `||` to concatenate strings in the H2 SET clause, but the H2 +-- connection runs in `MODE=MySQL`, where `||` is LOGICAL OR, not string +-- concatenation. The result is that V48's REPLACE() set `content` to a +-- garbage boolean expression, corrupting AGENTS.md for the seeded agents +-- and sending the LLM back to `cat < { } } - // 运行中且输入为空时,停止生成 + // 运行中且输入为空时,停止生成 —— 但当用户刚刚追加了一条 queued 消息时, + // 第二次点击发送/按 Enter 通常是误操作(双击 / 输入法回车 / 连击)。这种情况下 + // 触发 stop 会把用户预期会跑的当前 turn + queued 一起杀掉,前端给出"任务直接结束" + // 的错觉。检测到 sending 状态的 queued 消息时静默吞掉这次空提交,让用户必须明确 + // 点 cancel-queued 或专用 stop 按钮才能终止。 if (props.loading && !canSend.value) { + if (props.queuedMessage && (props.queuedMessage.status === 'queued' || props.queuedMessage.status === 'sending')) { + return + } emit('stop') return } diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index f460d555..f6bb9654 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -498,6 +498,7 @@ export function useChat(options: UseChatOptions): UseChatReturn { const metadata = parseMetadata((msg as any).metadata) const toolCalls = metadata?.toolCalls || [] toolCalls.push({ + toolCallId: data.toolCallId || '', name: data.toolName, arguments: data.arguments, status: 'running', @@ -508,12 +509,16 @@ export function useChat(options: UseChatOptions): UseChatReturn { metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName } } as any) } - // Segments: close any running thinking/content segment, then push a new tool_call segment + // Segments: close any running thinking/content segment, then push a new tool_call segment. + // Carry toolCallId so completes can pair back precisely; falling back to toolName-based + // pairing strands the first card with a permanent spinner whenever the LLM fires multiple + // calls of the same tool (observed with execute_shell_command + python3 retries). const segs = currentSegments.value const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content')) if (runningSeg) runningSeg.status = 'completed' segs.push({ id: genSegId(), type: 'tool_call', status: 'running', + toolCallId: data.toolCallId || '', toolName: data.toolName, toolArgs: data.arguments, timestamp: data.timestamp || Date.now(), }) @@ -528,10 +533,17 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (msg) { const metadata = parseMetadata((msg as any).metadata) const toolCalls = [...(metadata?.toolCalls || [])] - const lastRunning = toolCalls.findLastIndex((tc: any) => tc.status === 'running') - if (lastRunning >= 0) { - toolCalls[lastRunning] = { - ...toolCalls[lastRunning], + // Match by toolCallId when available, fall back to "first running" for legacy events. + let target = -1 + if (data.toolCallId) { + target = toolCalls.findIndex((tc: any) => tc.toolCallId === data.toolCallId && tc.status === 'running') + } + if (target < 0) { + target = toolCalls.findIndex((tc: any) => tc.status === 'running' && tc.name === data.toolName) + } + if (target >= 0) { + toolCalls[target] = { + ...toolCalls[target], result: data.result, success: data.success, status: 'completed' @@ -542,10 +554,17 @@ export function useChat(options: UseChatOptions): UseChatReturn { metadata: { ...metadata, toolCalls, runningToolName: undefined } } as any) } - // Segments: find the matching running tool_call segment and mark it complete + // Segments: prefer toolCallId match, fall back to first-running by toolName. const segs = currentSegments.value - const toolSeg = segs.findLast((s: MessageSegment) => - s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName) + let toolSeg: MessageSegment | undefined + if (data.toolCallId) { + toolSeg = segs.find((s: MessageSegment) => + s.type === 'tool_call' && s.status === 'running' && s.toolCallId === data.toolCallId) + } + if (!toolSeg) { + toolSeg = segs.find((s: MessageSegment) => + s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName) + } if (toolSeg) { toolSeg.status = data.success !== false ? 'completed' : 'error' toolSeg.toolResult = data.result diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 0e0896b7..d0696286 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -143,6 +143,8 @@ export interface MessageSegment { toolArgs?: string toolResult?: string toolSuccess?: boolean + /** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */ + toolCallId?: string /** type=content */ text?: string /** type=phase */