mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
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:
parent
b4ebab65c7
commit
349f4d7d3c
@ -52,8 +52,23 @@ public final class GraphEventPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static GraphEvent toolStart(String toolName, String arguments) {
|
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();
|
long ts = System.currentTimeMillis();
|
||||||
return new GraphEvent(EVENT_TOOL_START, Map.of(
|
return new GraphEvent(EVENT_TOOL_START, Map.of(
|
||||||
|
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||||
"toolName", toolName,
|
"toolName", toolName,
|
||||||
"arguments", arguments != null ? arguments : "",
|
"arguments", arguments != null ? arguments : "",
|
||||||
"timestamp", ts
|
"timestamp", ts
|
||||||
@ -61,8 +76,13 @@ public final class GraphEventPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
|
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();
|
long ts = System.currentTimeMillis();
|
||||||
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
||||||
|
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||||
"toolName", toolName,
|
"toolName", toolName,
|
||||||
"result", result != null ? truncateResult(result) : "",
|
"result", result != null ? truncateResult(result) : "",
|
||||||
"success", success,
|
"success", success,
|
||||||
|
|||||||
@ -232,7 +232,7 @@ public class ToolExecutionExecutor {
|
|||||||
String toolName = toolCall.name();
|
String toolName = toolCall.name();
|
||||||
String arguments = toolCall.arguments();
|
String arguments = toolCall.arguments();
|
||||||
|
|
||||||
events.add(GraphEventPublisher.toolStart(toolName, arguments));
|
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments));
|
||||||
|
|
||||||
// 0. 子会话工具拦截:委派上下文中的子 Agent 禁止调用特定工具
|
// 0. 子会话工具拦截:委派上下文中的子 Agent 禁止调用特定工具
|
||||||
if (vip.mate.tool.builtin.DelegationContext.currentDepth() > 0) {
|
if (vip.mate.tool.builtin.DelegationContext.currentDepth() > 0) {
|
||||||
@ -240,7 +240,7 @@ public class ToolExecutionExecutor {
|
|||||||
if (denied.contains(toolName)) {
|
if (denied.contains(toolName)) {
|
||||||
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
|
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
|
||||||
log.info("[ToolExecutor] Child agent blocked from using tool: {}", 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(
|
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, msg));
|
toolCall.id(), toolName, msg));
|
||||||
continue;
|
continue;
|
||||||
@ -255,7 +255,7 @@ public class ToolExecutionExecutor {
|
|||||||
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
||||||
toolName, arguments.length(), jsonEx.getMessage());
|
toolName, arguments.length(), jsonEx.getMessage());
|
||||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
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(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, truncationError));
|
toolCall.id(), toolName, truncationError));
|
||||||
continue;
|
continue;
|
||||||
@ -300,7 +300,7 @@ public class ToolExecutionExecutor {
|
|||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
if (callback == null) {
|
if (callback == null) {
|
||||||
log.warn("[ToolExecutor] Tool not found: {}", toolName);
|
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(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, "Tool not found: " + toolName));
|
toolCall.id(), toolName, "Tool not found: " + toolName));
|
||||||
continue;
|
continue;
|
||||||
@ -376,7 +376,7 @@ public class ToolExecutionExecutor {
|
|||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
if (callback == null) {
|
if (callback == null) {
|
||||||
log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName);
|
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);
|
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);
|
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
||||||
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
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(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, result != null ? result : "");
|
toolCall.id(), toolName, result != null ? result : "");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -422,7 +422,7 @@ public class ToolExecutionExecutor {
|
|||||||
String safeError = isReturnDirect(callback)
|
String safeError = isReturnDirect(callback)
|
||||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
: "Tool execution failed: " + e.getMessage();
|
: "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);
|
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -559,7 +559,7 @@ public class ToolExecutionExecutor {
|
|||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
streamTracker.updateRunningTool(pc.conversationId, toolName);
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_START,
|
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: {}",
|
log.info("[ToolExecutor] Executing tool: {} with args: {}",
|
||||||
toolName, pc.arguments != null && pc.arguments.length() > 200
|
toolName, pc.arguments != null && pc.arguments.length() > 200
|
||||||
@ -611,10 +611,10 @@ public class ToolExecutionExecutor {
|
|||||||
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
result = truncateToolResult(result, MAX_TOOL_RESULT_CHARS);
|
||||||
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
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) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
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);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
@ -629,10 +629,10 @@ public class ToolExecutionExecutor {
|
|||||||
String reportedError = isReturnDirect(pc.callback)
|
String reportedError = isReturnDirect(pc.callback)
|
||||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
: normalizeToolExecutionError(e);
|
: normalizeToolExecutionError(e);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolName, reportedError, false));
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
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);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
@ -653,7 +653,7 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
if (evaluation.shouldBlock()) {
|
if (evaluation.shouldBlock()) {
|
||||||
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
|
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(
|
return GuardDecision.blocked(
|
||||||
"[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。");
|
"[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。");
|
||||||
}
|
}
|
||||||
@ -672,7 +672,7 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
if (guardResult.isBlocked()) {
|
if (guardResult.isBlocked()) {
|
||||||
log.warn("[ToolExecutor] Tool call BLOCKED by ToolGuard: tool={}, reason={}", toolName, guardResult.reason());
|
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(
|
return GuardDecision.blocked(
|
||||||
"[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。");
|
"[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,14 @@ public class ApprovalController {
|
|||||||
log.info("[Approval] User {} {} pending {} for conversation {}",
|
log.info("[Approval] User {} {} pending {} for conversation {}",
|
||||||
username, decision, request.getPendingId(), conversationId);
|
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 拦截)
|
// Web 端的 replay 由前端发送 /approve 消息到 POST /stream 触发(ChatController 拦截)
|
||||||
// 此端点只更新审批状态,保留给 IM 渠道(DingTalk/Feishu 等通过 ChannelMessageRouter 调用)
|
// 此端点只更新审批状态,保留给 IM 渠道(DingTalk/Feishu 等通过 ChannelMessageRouter 调用)
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,37 @@ public class ApprovalService {
|
|||||||
log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 查询 ====================
|
// ==================== 查询 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -194,6 +194,11 @@ public class ChatController {
|
|||||||
if (isDenyCommand) {
|
if (isDenyCommand) {
|
||||||
approvalService.resolve(pending.getPendingId(), username, "denied");
|
approvalService.resolve(pending.getPendingId(), username, "denied");
|
||||||
conversationService.removeApprovalPlaceholders(conversationId);
|
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 {}",
|
log.info("[Approval-Stream] User {} denied pending {} for conversation {}",
|
||||||
username, pending.getPendingId(), conversationId);
|
username, pending.getPendingId(), conversationId);
|
||||||
}
|
}
|
||||||
@ -212,6 +217,11 @@ public class ChatController {
|
|||||||
}
|
}
|
||||||
// 清理 DB 中残留的审批占位消息(对齐 IM 渠道 replayApprovedToolCall)
|
// 清理 DB 中残留的审批占位消息(对齐 IM 渠道 replayApprovedToolCall)
|
||||||
conversationService.removeApprovalPlaceholders(conversationId);
|
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 {}",
|
log.info("[Approval-Stream] User {} approved pending {} for conversation {}",
|
||||||
username, consumed.getPendingId(), conversationId);
|
username, consumed.getPendingId(), conversationId);
|
||||||
}
|
}
|
||||||
@ -762,18 +772,50 @@ public class ChatController {
|
|||||||
/**
|
/**
|
||||||
* 停止指定会话的流式生成。
|
* 停止指定会话的流式生成。
|
||||||
* 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),已生成的部分内容以 stopped 状态入库。
|
* 取消 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 = "停止流式生成")
|
@Operation(summary = "停止流式生成")
|
||||||
@PostMapping("/{conversationId}/stop")
|
@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";
|
String username = auth != null ? auth.getName() : "anonymous";
|
||||||
// 权限校验:已认证用户需验证会话归属,匿名用户(permitAll)直接放行
|
// 权限校验:已认证用户需验证会话归属,匿名用户(permitAll)直接放行
|
||||||
if (auth != null && !conversationService.isConversationOwner(conversationId, username)) {
|
if (auth != null && !conversationService.isConversationOwner(conversationId, username)) {
|
||||||
return R.fail("无权操作该会话");
|
return R.fail("无权操作该会话");
|
||||||
}
|
}
|
||||||
boolean stopped = streamTracker.requestStop(conversationId);
|
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
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -68,9 +68,9 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) throws Exception {
|
public void run(ApplicationArguments args) throws Exception {
|
||||||
// Schema creation is now handled by Flyway (db/migration/)
|
// Schema creation and built-in tool registration are handled by
|
||||||
runToolSyncScript();
|
// 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()) {
|
if (isDataAlreadySeeded()) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
log.info("Database already initialized, skipping seed data");
|
log.info("Database already initialized, skipping seed data");
|
||||||
@ -168,12 +168,6 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
|
|||||||
return isMySQL;
|
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) {
|
private void runScript(String path) {
|
||||||
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
|
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
|
||||||
populator.setContinueOnError(false);
|
populator.setContinueOnError(false);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package vip.mate.config;
|
package vip.mate.config;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
@ -19,6 +20,7 @@ import vip.mate.channel.web.TalkModeWebSocketHandler;
|
|||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||||
public class WebSocketConfig implements WebSocketConfigurer {
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -152,14 +152,32 @@ public class ShellExecuteTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将命令中的嵌入换行符替换为空格。
|
* Collapse embedded newlines for Windows cmd.exe (where they break parsing),
|
||||||
* LLM 在 JSON tool_call 中产生的 \n 解码后变成真实换行,
|
* but **leave them alone on Unix**.
|
||||||
* 在 Windows cmd.exe 中会导致命令被截断,在 Unix sh 中可能被误解为命令分隔符。
|
* <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 <<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.
|
||||||
|
* <p>
|
||||||
|
* 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) {
|
private static String collapseEmbeddedNewlines(String command) {
|
||||||
if (command == null || !command.contains("\n")) {
|
if (command == null || !command.contains("\n")) {
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
if (!IS_WINDOWS) {
|
||||||
|
// POSIX shell handles newlines correctly within heredocs / scripts
|
||||||
|
return command;
|
||||||
|
}
|
||||||
return command.replace("\r\n", " ").replace("\n", " ");
|
return command.replace("\r\n", " ").replace("\n", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,25 @@
|
|||||||
package vip.mate.tool.guard.guardian;
|
package vip.mate.tool.guard.guardian;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import vip.mate.tool.guard.model.*;
|
import vip.mate.tool.guard.model.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件写入守卫
|
* File-write guardian — historically marked every write_file / edit_file call
|
||||||
* <p>
|
* as MEDIUM risk and forced an approval popup. Disabled (no @Component)
|
||||||
* 标记写文件/编辑文件操作为 MEDIUM 风险。
|
* because in-workspace writes are already path-bounded by
|
||||||
* 最终是否需要审批由 ToolPolicyResolver 决定。
|
* {@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
|
@Slf4j
|
||||||
@Component
|
|
||||||
public class FileWriteGuardian implements ToolGuardGuardian {
|
public class FileWriteGuardian implements ToolGuardGuardian {
|
||||||
|
|
||||||
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
||||||
|
|||||||
@ -513,6 +513,67 @@ public class ConversationService {
|
|||||||
* <p>
|
* <p>
|
||||||
* 在 replay 前调用,确保 LLM 上下文中不包含任何审批相关文本。
|
* 在 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
|
@Transactional
|
||||||
public void removeApprovalPlaceholders(String conversationId) {
|
public void removeApprovalPlaceholders(String conversationId) {
|
||||||
List<MessageEntity> messages = listMessages(conversationId);
|
List<MessageEntity> messages = listMessages(conversationId);
|
||||||
|
|||||||
@ -1916,7 +1916,7 @@ SELECT
|
|||||||
1000000001,
|
1000000001,
|
||||||
TRUE,
|
TRUE,
|
||||||
'all',
|
'all',
|
||||||
'["write_file","edit_file","execute_shell_command"]',
|
'["execute_shell_command"]',
|
||||||
'[]',
|
'[]',
|
||||||
TRUE,
|
TRUE,
|
||||||
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
||||||
|
|||||||
@ -1955,7 +1955,7 @@ VALUES (
|
|||||||
1000000001,
|
1000000001,
|
||||||
TRUE,
|
TRUE,
|
||||||
'all',
|
'all',
|
||||||
'["write_file","edit_file","execute_shell_command"]',
|
'["execute_shell_command"]',
|
||||||
'[]',
|
'[]',
|
||||||
TRUE,
|
TRUE,
|
||||||
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
||||||
|
|||||||
@ -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,
|
1000000001,
|
||||||
TRUE,
|
TRUE,
|
||||||
'all',
|
'all',
|
||||||
'["write_file","edit_file","execute_shell_command"]',
|
'["execute_shell_command"]',
|
||||||
'[]',
|
'[]',
|
||||||
TRUE,
|
TRUE,
|
||||||
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
||||||
|
|||||||
@ -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,
|
1000000001,
|
||||||
TRUE,
|
TRUE,
|
||||||
'all',
|
'all',
|
||||||
'["write_file","edit_file","execute_shell_command"]',
|
'["execute_shell_command"]',
|
||||||
'[]',
|
'[]',
|
||||||
TRUE,
|
TRUE,
|
||||||
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
||||||
|
|||||||
@ -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%';
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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%';
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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();
|
|
||||||
@ -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=FALSE),MERGE 只在 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);
|
|
||||||
@ -321,8 +321,15 @@ const handleSubmit = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 运行中且输入为空时,停止生成
|
// 运行中且输入为空时,停止生成 —— 但当用户刚刚追加了一条 queued 消息时,
|
||||||
|
// 第二次点击发送/按 Enter 通常是误操作(双击 / 输入法回车 / 连击)。这种情况下
|
||||||
|
// 触发 stop 会把用户预期会跑的当前 turn + queued 一起杀掉,前端给出"任务直接结束"
|
||||||
|
// 的错觉。检测到 sending 状态的 queued 消息时静默吞掉这次空提交,让用户必须明确
|
||||||
|
// 点 cancel-queued 或专用 stop 按钮才能终止。
|
||||||
if (props.loading && !canSend.value) {
|
if (props.loading && !canSend.value) {
|
||||||
|
if (props.queuedMessage && (props.queuedMessage.status === 'queued' || props.queuedMessage.status === 'sending')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('stop')
|
emit('stop')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -498,6 +498,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
const metadata = parseMetadata((msg as any).metadata)
|
const metadata = parseMetadata((msg as any).metadata)
|
||||||
const toolCalls = metadata?.toolCalls || []
|
const toolCalls = metadata?.toolCalls || []
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
|
toolCallId: data.toolCallId || '',
|
||||||
name: data.toolName,
|
name: data.toolName,
|
||||||
arguments: data.arguments,
|
arguments: data.arguments,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
@ -508,12 +509,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName }
|
metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName }
|
||||||
} as any)
|
} 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 segs = currentSegments.value
|
||||||
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
|
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
|
||||||
if (runningSeg) runningSeg.status = 'completed'
|
if (runningSeg) runningSeg.status = 'completed'
|
||||||
segs.push({
|
segs.push({
|
||||||
id: genSegId(), type: 'tool_call', status: 'running',
|
id: genSegId(), type: 'tool_call', status: 'running',
|
||||||
|
toolCallId: data.toolCallId || '',
|
||||||
toolName: data.toolName, toolArgs: data.arguments,
|
toolName: data.toolName, toolArgs: data.arguments,
|
||||||
timestamp: data.timestamp || Date.now(),
|
timestamp: data.timestamp || Date.now(),
|
||||||
})
|
})
|
||||||
@ -528,10 +533,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (msg) {
|
if (msg) {
|
||||||
const metadata = parseMetadata((msg as any).metadata)
|
const metadata = parseMetadata((msg as any).metadata)
|
||||||
const toolCalls = [...(metadata?.toolCalls || [])]
|
const toolCalls = [...(metadata?.toolCalls || [])]
|
||||||
const lastRunning = toolCalls.findLastIndex((tc: any) => tc.status === 'running')
|
// Match by toolCallId when available, fall back to "first running" for legacy events.
|
||||||
if (lastRunning >= 0) {
|
let target = -1
|
||||||
toolCalls[lastRunning] = {
|
if (data.toolCallId) {
|
||||||
...toolCalls[lastRunning],
|
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,
|
result: data.result,
|
||||||
success: data.success,
|
success: data.success,
|
||||||
status: 'completed'
|
status: 'completed'
|
||||||
@ -542,10 +554,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
metadata: { ...metadata, toolCalls, runningToolName: undefined }
|
metadata: { ...metadata, toolCalls, runningToolName: undefined }
|
||||||
} as any)
|
} 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 segs = currentSegments.value
|
||||||
const toolSeg = segs.findLast((s: MessageSegment) =>
|
let toolSeg: MessageSegment | undefined
|
||||||
s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName)
|
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) {
|
if (toolSeg) {
|
||||||
toolSeg.status = data.success !== false ? 'completed' : 'error'
|
toolSeg.status = data.success !== false ? 'completed' : 'error'
|
||||||
toolSeg.toolResult = data.result
|
toolSeg.toolResult = data.result
|
||||||
|
|||||||
@ -143,6 +143,8 @@ export interface MessageSegment {
|
|||||||
toolArgs?: string
|
toolArgs?: string
|
||||||
toolResult?: string
|
toolResult?: string
|
||||||
toolSuccess?: boolean
|
toolSuccess?: boolean
|
||||||
|
/** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */
|
||||||
|
toolCallId?: string
|
||||||
/** type=content */
|
/** type=content */
|
||||||
text?: string
|
text?: string
|
||||||
/** type=phase */
|
/** type=phase */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user