refactor(bootstrap): drop legacy tools-sync.sql in favor of per-tool Flyway migrations

The two tools-sync scripts ran on every startup and used H2 MERGE INTO
... KEY(id), which overwrites every column on existing rows. That
silently reverted UI-toggled `enabled` and was the proximate cause of
a recent WriteFileTool/EditFileTool outage.

They were also a strict subset of the fresh-install seed (data-zh.sql /
data-en.sql register all 19 builtins; the sync scripts only 16) and out
of date. Per-tool Flyway migrations (V3, V31) are already the canonical
'register a new builtin' path, so the sync layer was duplicated and
error-prone.

Delete both files and the runToolSyncScript() loader. Tool descriptions
shown to the LLM come from @Tool annotations in code, not the DB row,
so removing per-startup metadata refresh has no functional impact.
This commit is contained in:
matevip 2026-04-27 14:00:08 +08:00
parent b4ebab65c7
commit 349f4d7d3c
27 changed files with 491 additions and 185 deletions

View File

@ -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,

View File

@ -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() + "。请使用更安全的替代方案。");
}

View File

@ -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 调用

View File

@ -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.
* <p>
* 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<PendingApproval> denyAllByConversation(String conversationId, String userId) {
Instant now = Instant.now();
List<PendingApproval> 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;
}
// ==================== 查询 ====================
/**

View File

@ -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 状态入库
* <p>
* 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<Map<String, Boolean>> stopStream(@PathVariable String conversationId, Authentication auth) {
public R<Map<String, Object>> 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<vip.mate.approval.PendingApproval> denied =
approvalService.denyAllByConversation(conversationId, username);
int messagesRewritten = 0;
if (!denied.isEmpty()) {
java.util.Set<String> 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
));
}
/**

View File

@ -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>_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);

View File

@ -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 {
/**

View File

@ -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**.
* <p>
* 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 &lt;&lt;EOF\nbody\nEOF`) and
* `python &lt;&lt;EOF` invocations. Both depend on real line breaks to
* delimit the body from the closing tag collapsing newlines turns
* `cat &lt;&lt;EOF\nbody\nEOF` into `cat &lt;&lt;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.
* <p>
* Unix shell already separates commands with `;` or `&amp;&amp;`, 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", " ");
}

View File

@ -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;
/**
* 文件写入守卫
* <p>
* 标记写文件/编辑文件操作为 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<String> FILE_WRITE_TOOL_NAMES = Set.of(

View File

@ -513,6 +513,67 @@ public class ConversationService {
* <p>
* 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.
* <p>
* 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<String> resolvedPendingIds,
String newStatus) {
if (conversationId == null || resolvedPendingIds == null || resolvedPendingIds.isEmpty()) {
return 0;
}
String targetStatus = (newStatus == null || newStatus.isBlank()) ? "denied" : newStatus;
List<MessageEntity> 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<String, Object> meta = objectMapper.readValue(raw, new TypeReference<>() {});
Object pa = meta.get("pendingApproval");
if (!(pa instanceof java.util.Map)) continue;
@SuppressWarnings("unchecked")
java.util.Map<String, Object> pendingApproval = (java.util.Map<String, Object>) 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<MessageEntity> messages = listMessages(conversationId);

View File

@ -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"]',

View File

@ -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"]',

View File

@ -1723,7 +1723,7 @@ VALUES (
## 边界
-
- execute_shell_commandread_file
- read_filewrite_file / printf / heredoc / echo edit_fileexecute_shell_command
-
## 风格
@ -1870,7 +1870,7 @@ VALUES (
## 边界
-
- execute_shell_commandread_file
- read_filewrite_file / printf / heredoc / echo edit_fileexecute_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"]',

View File

@ -1687,7 +1687,7 @@ VALUES (
## 边界
-
- execute_shell_commandread_file
- read_filewrite_file / printf / heredoc / echo edit_fileexecute_shell_command
-
## 风格
@ -1834,7 +1834,7 @@ VALUES (
## 边界
-
- execute_shell_commandread_file
- read_filewrite_file / printf / heredoc / echo edit_fileexecute_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"]',

View File

@ -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%';

View File

@ -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;

View File

@ -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 <<EOF` shell tricks instead of
-- calling write_file.
--
-- This migration rewrites the offending paragraph using CONCAT() (which
-- works under both H2 native and MODE=MySQL). It tolerates whatever V48
-- left behind because it overwrites the entire content for the three
-- seeded agents to a known-good template.
--
-- Idempotent: only touches AGENTS.md rows for the three seeded agents.
UPDATE mate_workspace_file
SET content = CONCAT(
'## 记忆', CHAR(10),
CHAR(10),
'你的记忆由数据库工作区文件提供连续性:', CHAR(10),
CHAR(10),
'- `PROFILE.md`:稳定用户画像与协作偏好', CHAR(10),
'- `MEMORY.md`:长期事实、经验教训、工具设置、反复出现的模式', CHAR(10),
'- `memory/YYYY-MM-DD.md`:当日事件、观察、一次性上下文', CHAR(10),
CHAR(10),
'### 记忆策略', CHAR(10),
CHAR(10),
'- 稳定信息进入 `PROFILE.md` 或 `MEMORY.md`', CHAR(10),
'- 临时事件进入 `memory/YYYY-MM-DD.md`', CHAR(10),
'- 修改前先读取原文,优先做增量编辑而不是整篇重写', CHAR(10),
'- 避免记录敏感信息,除非用户明确要求', CHAR(10),
CHAR(10),
'### 主动召回', CHAR(10),
CHAR(10),
'- 遇到历史偏好、旧决策、持续任务、用户习惯时,优先查看工作区记忆', CHAR(10),
'- 不确定具体发生日期时,检查相关 `memory/YYYY-MM-DD.md`', CHAR(10),
CHAR(10),
'## 安全', CHAR(10),
CHAR(10),
'- 绝不泄露私密数据。', CHAR(10),
'- 拿不准的事情,先确认。', CHAR(10),
CHAR(10),
'## 边界', CHAR(10),
CHAR(10),
'- 私密的保持私密。', CHAR(10),
'- 需要执行文件操作或命令时,**必须**调用对应的工具:', CHAR(10),
' - 读文件 → `read_file`', CHAR(10),
' - 写新文件或覆盖整个文件 → `write_file`(一次写完整内容,不要用 printf / heredoc / echo / cat << EOF 拼字符串)', CHAR(10),
' - 修改已有文件局部内容 → `edit_file`', CHAR(10),
' - 执行 shell 命令 → `execute_shell_command`', CHAR(10),
' 禁止用 shell 命令绕过 `write_file` 写文件。系统会自动对危险操作弹出审批确认。', CHAR(10),
'- 拿不准就先问。', CHAR(10),
CHAR(10),
'## 风格', CHAR(10),
CHAR(10),
'该简洁就简洁,重要时详细。', CHAR(10),
CHAR(10),
'## 连续性', CHAR(10),
CHAR(10),
'每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。', CHAR(10)
)
WHERE filename = 'AGENTS.md'
AND agent_id IN (1000000001, 1000000002, 1000000003);

View File

@ -0,0 +1,22 @@
-- V51: Remove write_file / edit_file from the global ToolGuard guarded list.
--
-- Scenarios where the agent legitimately writes 20+ chapter / config / asset
-- files in a single turn (project proposals, code refactors, scaffolding,
-- batch i18n updates) drown the user in approval popups — every single
-- write_file becomes "允许执行写文件?" and the workflow stalls. The original
-- intent of guarding these tools was to catch path-traversal exfiltration,
-- but `WorkspacePathGuard.validatePath()` already rejects any path outside
-- the active workspace before write_file / edit_file even run, so the
-- approval prompt is largely redundant.
--
-- This migration narrows the global guarded_tools_json to just
-- `execute_shell_command`, which is the genuinely dangerous surface (it can
-- shell out to rm, curl exfiltrate, etc.) and where per-call approval is
-- worth the friction. Operators who want write/edit guarded again can
-- toggle them back via the admin UI.
--
-- Idempotent: the WHERE clause matches only the global config row.
UPDATE mate_tool_guard_config
SET guarded_tools_json = '["execute_shell_command"]'
WHERE id = 1000000001;

View File

@ -0,0 +1,23 @@
-- V48 (MySQL): see h2/V48 for full rationale.
-- Same SQL semantics; CHAR(10) and CONCAT-style string building work identically here,
-- but MySQL's `||` is logical OR by default — use CONCAT() instead.
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 等),不要用文本描述你要做什么。',
CONCAT(
'需要执行文件操作或命令时,直接调用对应的工具:', 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%';

View File

@ -0,0 +1,5 @@
-- V49 (MySQL): see h2/V49 for full rationale.
UPDATE mate_tool SET enabled = TRUE
WHERE bean_name IN ('writeFileTool', 'editFileTool')
AND enabled = FALSE;

View File

@ -0,0 +1,52 @@
-- V50 (MySQL): mirror of h2/V50. MySQL's `||` is also OR by default,
-- but V48 mysql version already used CONCAT() correctly so this is a
-- safe-no-op rewrite to keep the two seed branches in sync.
UPDATE mate_workspace_file
SET content = CONCAT(
'## 记忆', CHAR(10),
CHAR(10),
'你的记忆由数据库工作区文件提供连续性:', CHAR(10),
CHAR(10),
'- `PROFILE.md`:稳定用户画像与协作偏好', CHAR(10),
'- `MEMORY.md`:长期事实、经验教训、工具设置、反复出现的模式', CHAR(10),
'- `memory/YYYY-MM-DD.md`:当日事件、观察、一次性上下文', CHAR(10),
CHAR(10),
'### 记忆策略', CHAR(10),
CHAR(10),
'- 稳定信息进入 `PROFILE.md` 或 `MEMORY.md`', CHAR(10),
'- 临时事件进入 `memory/YYYY-MM-DD.md`', CHAR(10),
'- 修改前先读取原文,优先做增量编辑而不是整篇重写', CHAR(10),
'- 避免记录敏感信息,除非用户明确要求', CHAR(10),
CHAR(10),
'### 主动召回', CHAR(10),
CHAR(10),
'- 遇到历史偏好、旧决策、持续任务、用户习惯时,优先查看工作区记忆', CHAR(10),
'- 不确定具体发生日期时,检查相关 `memory/YYYY-MM-DD.md`', CHAR(10),
CHAR(10),
'## 安全', CHAR(10),
CHAR(10),
'- 绝不泄露私密数据。', CHAR(10),
'- 拿不准的事情,先确认。', CHAR(10),
CHAR(10),
'## 边界', CHAR(10),
CHAR(10),
'- 私密的保持私密。', CHAR(10),
'- 需要执行文件操作或命令时,**必须**调用对应的工具:', CHAR(10),
' - 读文件 → `read_file`', CHAR(10),
' - 写新文件或覆盖整个文件 → `write_file`(一次写完整内容,不要用 printf / heredoc / echo / cat << EOF 拼字符串)', CHAR(10),
' - 修改已有文件局部内容 → `edit_file`', CHAR(10),
' - 执行 shell 命令 → `execute_shell_command`', CHAR(10),
' 禁止用 shell 命令绕过 `write_file` 写文件。系统会自动对危险操作弹出审批确认。', CHAR(10),
'- 拿不准就先问。', CHAR(10),
CHAR(10),
'## 风格', CHAR(10),
CHAR(10),
'该简洁就简洁,重要时详细。', CHAR(10),
CHAR(10),
'## 连续性', CHAR(10),
CHAR(10),
'每次会话都全新醒来。工作区文件就是你的记忆。读它们。更新它们。', CHAR(10)
)
WHERE filename = 'AGENTS.md'
AND agent_id IN (1000000001, 1000000002, 1000000003);

View File

@ -0,0 +1,5 @@
-- V51 (MySQL): see h2/V51 for full rationale.
UPDATE mate_tool_guard_config
SET guarded_tools_json = '["execute_shell_command"]'
WHERE id = 1000000001;

View File

@ -1,63 +0,0 @@
-- ==================== 内置工具同步MySQL / MariaDB 专用) ====================
-- 每次启动都执行INSERT ... ON DUPLICATE KEY UPDATE 是幂等的。
-- 新增内置工具时在此文件追加一条 INSERT重启后即生效。
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000001, 'DateTimeTool', '日期时间', '获取当前日期和时间信息', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000002, 'WebSearchTool', '网络搜索', '在互联网上搜索实时信息', 'builtin', 'webSearchTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000003, 'ShellExecuteTool', '命令执行', '在本地服务器上执行 Shell 命令。用于执行系统命令、查看文件、运行脚本等操作。危险操作会触发审批确认。', 'builtin', 'shellExecuteTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000004, 'ReadFileTool', '读取文件', '读取指定文件的内容,支持按行范围读取,自动截断超大输出。', 'builtin', 'readFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', FALSE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', FALSE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000007, 'SkillFileTool', '技能文件读取', '读取技能包内的文件SKILL.md/references/scripts列出技能文件目录树。支持 read_skill_file 和 list_skill_files 两个工具。', 'builtin', 'skillFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000008, 'SkillScriptTool', '技能脚本执行', '执行技能包 scripts/ 目录下的脚本Python/Bash/Node路径严格限制在技能目录内。', 'builtin', 'skillScriptTool', '', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000009, 'FileTypeDetectorTool', '文件类型检测', '检测文件的 MIME 类型和类别,区分文本文件和 PDF/Office 文档,帮助选择合适的读取工具。', 'builtin', 'fileTypeDetectorTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000010, 'DocumentExtractTool', '文档文本提取', '从 PDF、Word、Excel、PowerPoint 等 Office 文档中提取纯文本内容。支持 fallback 链系统命令优先Java 实现兜底。', 'builtin', 'documentExtractTool', '📄', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000011, 'WorkspaceMemoryTool', '工作区记忆', '读写数据库中的工作区 Markdown 文档,用于维护 PROFILE.md、MEMORY.md 和 memory/YYYY-MM-DD.md 等持久记忆。', 'builtin', 'workspaceMemoryTool', '🧠', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000014, 'DelegateAgentTool', 'Agent 委派', '委派任务给其他 Agent 执行,实现多 Agent 协作。支持按名称调用目标 Agent在独立会话中运行并返回结果。', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000015, 'DatasourceTool', '数据源查询', '查询外部数据源的元数据:列出可用数据源、查看表列表、查看表结构(列名/类型/注释)。支持 MySQL、PostgreSQL、ClickHouse。', 'builtin', 'datasourceTool', '🗄', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000016, 'SqlQueryTool', 'SQL 查询', '在外部数据源上执行只读 SQL 查询。仅允许 SELECT 语句,自动添加 LIMIT 保护,结果格式化为表格展示。', 'builtin', 'sqlQueryTool', '📊', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000017, 'WikiTool', 'Wiki 知识库', '读取、搜索 Wiki 知识库中的结构化页面,并追溯原始来源文件。支持 wiki_read_page、wiki_list_pages、wiki_search_pages、wiki_trace_source 四个工具。', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();

View File

@ -1,70 +0,0 @@
-- ==================== 内置工具同步每次启动都执行MERGE 是幂等的) ====================
-- 只包含 mate_tool 注册,不包含工作区文件等用户数据。
-- 新增内置工具时在此文件追加一条 MERGE重启后即生效无需重建数据库。
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000001, 'DateTimeTool', '日期时间', '获取当前日期和时间信息', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000002, 'WebSearchTool', '网络搜索', '在互联网上搜索实时信息', 'builtin', 'webSearchTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000003, 'ShellExecuteTool', '命令执行', '在本地服务器上执行 Shell 命令。用于执行系统命令、查看文件、运行脚本等操作。危险操作会触发审批确认。', 'builtin', 'shellExecuteTool', '🖥', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000004, 'ReadFileTool', '读取文件', '读取指定文件的内容,支持按行范围读取,自动截断超大输出。', 'builtin', 'readFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0);
-- WriteFileTool / EditFileTool 默认禁用enabled=FALSEMERGE 只在 id 不存在时才插入默认值
-- 如果用户已经手动在 UI 改为启用,此处的 MERGE 不会把 enabled 重置
-- 注意MERGE KEY(id) 在 id 已存在时会覆写 enabled所以保持与 data.sql 一致的默认值即可)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000005, 'WriteFileTool', '写入文件', '将内容写入指定文件。如果文件已存在则完全覆写,不存在则创建新文件。每次执行需要用户审批确认。', 'builtin', 'writeFileTool', '📝', FALSE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', FALSE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000007, 'SkillFileTool', '技能文件读取', '读取技能包内的文件SKILL.md/references/scripts列出技能文件目录树。支持 read_skill_file 和 list_skill_files 两个工具。', 'builtin', 'skillFileTool', '📖', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000008, 'SkillScriptTool', '技能脚本执行', '执行技能包 scripts/ 目录下的脚本Python/Bash/Node路径严格限制在技能目录内。', 'builtin', 'skillScriptTool', '', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000009, 'FileTypeDetectorTool', '文件类型检测', '检测文件的 MIME 类型和类别,区分文本文件和 PDF/Office 文档,帮助选择合适的读取工具。', 'builtin', 'fileTypeDetectorTool', '🔍', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000010, 'DocumentExtractTool', '文档文本提取', '从 PDF、Word、Excel、PowerPoint 等 Office 文档中提取纯文本内容。支持 fallback 链系统命令优先Java 实现兜底。', 'builtin', 'documentExtractTool', '📄', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000011, 'WorkspaceMemoryTool', '工作区记忆', '读写数据库中的工作区 Markdown 文档,用于维护 PROFILE.md、MEMORY.md 和 memory/YYYY-MM-DD.md 等持久记忆。', 'builtin', 'workspaceMemoryTool', '🧠', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000014, 'DelegateAgentTool', 'Agent 委派', '委派任务给其他 Agent 执行,实现多 Agent 协作。支持按名称调用目标 Agent在独立会话中运行并返回结果。', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000015, 'DatasourceTool', '数据源查询', '查询外部数据源的元数据:列出可用数据源、查看表列表、查看表结构(列名/类型/注释)。支持 MySQL、PostgreSQL、ClickHouse。', 'builtin', 'datasourceTool', '🗄', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000016, 'SqlQueryTool', 'SQL 查询', '在外部数据源上执行只读 SQL 查询。仅允许 SELECT 语句,自动添加 LIMIT 保护,结果格式化为表格展示。', 'builtin', 'sqlQueryTool', '📊', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000017, 'WikiTool', 'Wiki 知识库', '读取、搜索 Wiki 知识库中的结构化页面,并追溯原始来源文件。支持 wiki_read_page、wiki_list_pages、wiki_search_pages、wiki_trace_source 四个工具。', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000018, 'CronJobTool', '定时任务', '通过对话创建、查看、启停和删除定时任务。支持 5 字段 cron 表达式,灵活设定执行时间。', 'builtin', 'cronJobTool', '', TRUE, TRUE, NOW(), NOW(), 0);

View File

@ -321,8 +321,15 @@ const handleSubmit = () => {
}
}
//
// 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
}

View File

@ -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

View File

@ -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 */