mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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) {
|
||||
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,
|
||||
|
||||
@ -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() + "。请使用更安全的替代方案。");
|
||||
}
|
||||
|
||||
@ -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 调用)
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@ -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 <<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) {
|
||||
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", " ");
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]',
|
||||
|
||||
@ -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"]',
|
||||
|
||||
@ -1723,7 +1723,7 @@ VALUES (
|
||||
## 边界
|
||||
|
||||
- 私密的保持私密。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 拿不准就先问。
|
||||
|
||||
## 风格
|
||||
@ -1870,7 +1870,7 @@ VALUES (
|
||||
## 边界
|
||||
|
||||
- 私密的保持私密。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 拿不准就先问。
|
||||
|
||||
## 风格
|
||||
@ -1956,7 +1956,7 @@ VALUES (
|
||||
1000000001,
|
||||
TRUE,
|
||||
'all',
|
||||
'["write_file","edit_file","execute_shell_command"]',
|
||||
'["execute_shell_command"]',
|
||||
'[]',
|
||||
TRUE,
|
||||
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
||||
|
||||
@ -1687,7 +1687,7 @@ VALUES (
|
||||
## 边界
|
||||
|
||||
- 私密的保持私密。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 拿不准就先问。
|
||||
|
||||
## 风格
|
||||
@ -1834,7 +1834,7 @@ VALUES (
|
||||
## 边界
|
||||
|
||||
- 私密的保持私密。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具(如 execute_shell_command、read_file 等),不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 需要执行文件操作或命令时,直接调用对应的工具:read_file(读文件)、write_file(写新文件 / 覆盖整个文件,一次写完整内容,不要用 printf / heredoc / echo 拼)、edit_file(修改局部)、execute_shell_command(执行命令)。不要用文本描述你要做什么。系统会自动对危险操作弹出审批确认。
|
||||
- 拿不准就先问。
|
||||
|
||||
## 风格
|
||||
@ -1920,7 +1920,7 @@ SELECT
|
||||
1000000001,
|
||||
TRUE,
|
||||
'all',
|
||||
'["write_file","edit_file","execute_shell_command"]',
|
||||
'["execute_shell_command"]',
|
||||
'[]',
|
||||
TRUE,
|
||||
'["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]',
|
||||
|
||||
@ -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.queuedMessage && (props.queuedMessage.status === 'queued' || props.queuedMessage.status === 'sending')) {
|
||||
return
|
||||
}
|
||||
emit('stop')
|
||||
return
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user