From 4a95e7dfe4060cbb94c0ef25f26db6b946ad5377 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 25 Apr 2026 19:02:35 +0800 Subject: [PATCH] feat(tool): tool returnDirect and sensitive-data quarantine --- .../main/java/vip/mate/agent/BaseAgent.java | 151 +++++++++++++++- .../vip/mate/agent/GraphEventPublisher.java | 23 +++ .../graph/edge/ObservationDispatcher.java | 11 ++ .../graph/executor/ToolExecutionExecutor.java | 161 ++++++++++++++++-- .../vip/mate/agent/graph/node/ActionNode.java | 27 +++ .../agent/graph/node/FinalAnswerNode.java | 51 ++++++ .../graph/plan/node/StepExecutionNode.java | 82 ++++++++- .../agent/graph/state/DirectToolOutput.java | 24 +++ .../mate/agent/graph/state/FinishReason.java | 6 +- .../graph/state/MateClawStateAccessor.java | 20 +++ .../agent/graph/state/MateClawStateKeys.java | 15 ++ .../vip/mate/channel/web/ChatController.java | 21 +++ .../runtime/McpReturnDirectProperties.java | 52 ++++++ .../mcp/runtime/McpToolCallbackProvider.java | 23 ++- .../runtime/ReturnDirectMcpToolCallback.java | 62 +++++++ 15 files changed, 702 insertions(+), 27 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/graph/state/DirectToolOutput.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index dbcf49a4..76aba9a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -217,13 +217,7 @@ public abstract class BaseAgent { List messages = new ArrayList<>(limit); for (int i = 0; i < limit; i += 1) { - MessageEntity entity = history.get(i); - // 过滤审批占位消息,确保 LLM 上下文不包含审批残留 - if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) { - log.debug("[{}] Filtering approval placeholder from history: msgId={}", agentName, entity.getId()); - continue; - } - Message springMessage = toSpringMessage(entity); + Message springMessage = sanitizeForLlm(history.get(i)); if (springMessage != null) { messages.add(springMessage); } @@ -231,6 +225,51 @@ public abstract class BaseAgent { return messages; } + /** + * History sanitization entry point. Encapsulates *all* steps applied to a + * persisted message before it reaches an LLM prompt. Returns {@code null} + * to drop the message, or a Spring AI {@link Message} (possibly with + * rewritten content) to keep it. + * + *

Design philosophy (OpenClaw-inspired): keep the conversion + every + * sanitization stage centralized here so future steps (RFC-052 §9 PII + * field-level redaction, RFC-049 thinking-block replay strategy, image + * compression for vision models, etc.) plug in as additional inline + * stages with clear ordering rather than scattering across the loop. + * + *

Current stages (in order): + *

    + *
  1. Drop approval placeholders — assistant messages whose + * content is a "[等待审批]" stub from the approval flow are removed + * entirely so they don't pollute the LLM context.
  2. + *
  3. Render content — convert {@code MessageEntity} to a string + * via {@link ConversationService#renderMessageContent}.
  4. + *
  5. Direct-tool scrub (RFC-052) — assistant messages produced + * by a returnDirect tool path get their content replaced with a + * tool-named placeholder; the original DB content is unchanged.
  6. + *
  7. Type dispatch — wrap into {@code AssistantMessage}, + * {@code SystemMessage}, or {@code UserMessage} (with multimodal + * Media for image/video parts).
  8. + *
+ */ + private Message sanitizeForLlm(MessageEntity entity) { + if (entity == null) { + return null; + } + + // Stage 1: drop approval-placeholder assistant messages + if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) { + log.debug("[{}] Filtering approval placeholder from history: msgId={}", + agentName, entity.getId()); + return null; + } + + // Delegate stages 2-4 to toSpringMessage; the stage 3 scrub is applied + // there so the rendered content is replaced before the typed Message + // wrapper is constructed. + return toSpringMessage(entity); + } + /** * 判断消息是否为持久化的压缩摘要。 */ @@ -256,6 +295,86 @@ public abstract class BaseAgent { return ApprovalPlaceholderUtil.isApprovalPlaceholder(content); } + /** + * RFC-052: regex matching {@code "directToolNames":["a","b",...]} in the + * metadata JSON and capturing every tool name in group(1) iterations. The + * {@code \\s*} guards keep us robust to pretty-printed JSON. + * + *

Design note (OpenClaw-inspired): rather than a one-shot "is this a + * direct turn?" boolean we extract the actual tool names and weave them + * into the placeholder, so the next LLM turn can reason about *which* tool + * answered (e.g. "the user just asked their salary; you used + * query_employee_salary; if they ask follow-up questions, call it again"). + * This preserves conversational continuity that a generic placeholder + * destroys. + */ + private static final java.util.regex.Pattern DIRECT_TOOL_NAMES_ARRAY = + java.util.regex.Pattern.compile( + "\"directToolNames\"\\s*:\\s*\\[(\\s*\"[^\"]*\"\\s*(?:,\\s*\"[^\"]*\"\\s*)*)\\]"); + private static final java.util.regex.Pattern DIRECT_TOOL_NAMES_INNER = + java.util.regex.Pattern.compile("\"([^\"]+)\""); + + /** + * RFC-052: returns the list of returnDirect tool names recorded in the + * persisted assistant message's metadata. Empty list means this is NOT a + * direct-tool message and the content is safe for the LLM. + * + *

Allocates only when a non-empty {@code directToolNames} array is + * actually present (the common case — normal assistant turns — exits at + * the first {@code contains} check with zero allocations). + */ + static List directToolNamesIn(MessageEntity msg) { + if (msg == null) return List.of(); + String metadata = msg.getMetadata(); + if (metadata == null || metadata.isEmpty()) return List.of(); + if (!metadata.contains("\"directToolNames\"")) return List.of(); + java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata); + if (!arrayMatcher.find()) return List.of(); + String inner = arrayMatcher.group(1); + java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner); + List names = new ArrayList<>(2); + while (nameMatcher.find()) { + names.add(nameMatcher.group(1)); + } + return names; + } + + /** + * Convenience wrapper preserved for callers that only need the boolean. + * Keeps the original test surface stable. + */ + static boolean isDirectToolMessage(MessageEntity msg) { + return !directToolNamesIn(msg).isEmpty(); + } + + /** + * RFC-052: build the placeholder text used to replace a direct-tool + * assistant message in next-turn prompts. Includes the originating tool + * names so the model retains conversational structure (it knows *why* + * the content is redacted and *which* tool would re-fetch it). The + * original message stays unchanged in {@code mate_message.content}. + * + *

Worded as a neutral status line, not as a faux assistant utterance — + * the model treats it as a system-level note, not as previous output to + * be continued. + */ + static String directToolHistoryPlaceholder(List toolNames) { + if (toolNames == null || toolNames.isEmpty()) { + return "[Previous answer was tool data returned directly to the user. " + + "Content withheld from model context per tool policy.]"; + } + String joined = toolNames.size() == 1 + ? "'" + toolNames.get(0) + "'" + : toolNames.stream() + .map(n -> "'" + n + "'") + .reduce((a, b) -> a + ", " + b) + .orElse(""); + return "[Previous turn used direct-return tool(s) " + joined + " to deliver " + + "data straight to the user. Content withheld from model context per tool " + + "policy. If the user asks a follow-up that requires that data, call the " + + "tool again.]"; + } + private Message toSpringMessage(MessageEntity message) { if (message == null) { return null; @@ -264,6 +383,24 @@ public abstract class BaseAgent { if (renderedContent == null || renderedContent.isBlank()) { return null; } + // RFC-052: scrub direct-tool content from any subsequent LLM prompt. + // The DB content stays unchanged; only the in-memory Message handed to + // the model gets replaced. This is MateClaw's persistence-aware analog + // of joyagent-jdgenie's Memory.clearToolContext (purely in-memory) and + // OpenClaw's stripToolResultDetails (structural strip per replay). + // + // Unlike a generic "withheld" placeholder, we name the originating + // tool(s) so the model retains the dialog structure: it knows what + // kind of data was withheld and which tool would fetch it again. This + // preserves multi-turn coherence without leaking the payload itself. + if ("assistant".equals(message.getRole())) { + List directNames = directToolNamesIn(message); + if (!directNames.isEmpty()) { + log.debug("[{}] Scrubbing direct-tool content from history msgId={} tools={} (RFC-052)", + agentName, message.getId(), directNames); + renderedContent = directToolHistoryPlaceholder(directNames); + } + } return switch (message.getRole()) { case "assistant" -> new AssistantMessage(renderedContent); case "system" -> new SystemMessage(renderedContent); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java index a398e6b9..6a98706c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java @@ -29,6 +29,12 @@ public final class GraphEventPublisher { public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested"; /** RFC-06 D-6: lightweight performance summary emitted per-phase. */ public static final String EVENT_PERF_SUMMARY = "perf_summary"; + /** + * RFC-052: a tool with returnDirect=true completed; its full result is + * carried in the payload and is intended to be rendered as part of the + * assistant message (renderAs=assistant_message), bypassing the LLM. + */ + public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result"; /** * 事件记录 @@ -131,6 +137,23 @@ public final class GraphEventPublisher { * @param phase e.g. "triage", "reasoning", "tool_execution" * @param metrics arbitrary key-value pairs (e.g. "retry_count", "backoff_wait_ms") */ + /** + * RFC-052: emit a tool result that was produced by a returnDirect tool. + * The full text is carried verbatim and the {@code renderAs="assistant_message"} + * hint instructs the SSE consumer (front-end / accumulator) to fold the + * payload into the assistant bubble rather than into a tool card. + */ + public static GraphEvent toolDirectResult(String toolCallId, String toolName, String fullResult) { + long ts = System.currentTimeMillis(); + Map data = new java.util.LinkedHashMap<>(); + data.put("toolCallId", toolCallId != null ? toolCallId : ""); + data.put("toolName", toolName != null ? toolName : ""); + data.put("result", fullResult != null ? fullResult : ""); + data.put("renderAs", "assistant_message"); + data.put("timestamp", ts); + return new GraphEvent(EVENT_TOOL_DIRECT_RESULT, Map.copyOf(data), ts); + } + public static GraphEvent perfSummary(String phase, Map metrics) { long ts = System.currentTimeMillis(); Map data = new java.util.HashMap<>(metrics); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ObservationDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ObservationDispatcher.java index 7ab451a4..5659b5a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ObservationDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ObservationDispatcher.java @@ -38,6 +38,17 @@ public class ObservationDispatcher implements EdgeAction { return FINAL_ANSWER_NODE; } + // RFC-052: returnDirect short-circuit — highest priority after approval. + // Any tool in the latest batch declared returnDirect=true: skip the next + // LLM call entirely and route straight to FinalAnswerNode, which will + // assemble the final answer from DIRECT_TOOL_OUTPUTS. + if (accessor.returnDirectTriggered()) { + log.info("[ObservationDispatcher] RETURN_DIRECT_TRIGGERED=true, " + + "routing to finalAnswerNode (skipping next LLM call), iteration {}/{}", + currentIteration, maxIterations); + return FINAL_ANSWER_NODE; + } + // 1. 迭代超限检查(maxIterations=0 表示不限制) if (maxIterations > 0 && currentIteration >= maxIterations) { log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " + diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 7cf06e7a..c56edf07 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -8,6 +8,7 @@ import org.springframework.ai.tool.ToolCallback; import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.tool.guard.ToolExecutionGuardHelper; @@ -77,6 +78,15 @@ public class ToolExecutionExecutor { private static final java.util.regex.Pattern ERROR_TAIL_PATTERN = java.util.regex.Pattern.compile( "(?i)\\b(error|exception|traceback|failed|fatal|panic|stack.?trace|errno)\\b"); + /** + * RFC-052 §2.4: placeholder written into {@code ToolResponseMessage.content} + * for returnDirect tools. Intentionally English, short, and free of any + * tool-specific data so it is safe to enter prompt cache and gives the LLM + * a clear signal that the tool ran (vs failed). + */ + static final String DIRECT_TOOL_PLACEHOLDER = + "[Tool result returned directly to user. Content withheld from model context per tool policy.]"; + /** * 智能截断工具结果:检测尾部是否含错误信息,动态调整 head/tail 比例。 * 错误信息在尾部时保留 80% tail,确保 agent 能看到错误原因。 @@ -207,6 +217,9 @@ public class ToolExecutionExecutor { this.currentWorkspaceBasePath = workspaceBasePath; List allResponses = new ArrayList<>(); List events = Collections.synchronizedList(new ArrayList<>()); + // RFC-052: accumulate full-text outputs from returnDirect tools so the + // graph can route to FinalAnswerNode without re-entering the LLM. + List directOutputs = Collections.synchronizedList(new ArrayList<>()); events.add(GraphEventPublisher.phase("action", Map.of("toolCount", toolCalls.size()))); @@ -303,7 +316,7 @@ public class ToolExecutionExecutor { // ═══ Phase 2: 分段并发执行 ═══ if (!preparedCalls.isEmpty()) { - executePreparedCalls(preparedCalls, allResponses, events); + executePreparedCalls(preparedCalls, allResponses, events, directOutputs); } // Defensive: drop null placeholders (should never appear in practice). @@ -320,7 +333,8 @@ public class ToolExecutionExecutor { boolean hasApprovalPending = barrier != null; return new ToolExecutionResult(allResponses, events, hasApprovalPending, barrier != null ? barrier.pendingId : null, - barrier != null ? barrier.toolName : null); + barrier != null ? barrier.toolName : null, + List.copyOf(directOutputs)); } /** @@ -336,6 +350,26 @@ public class ToolExecutionExecutor { AssistantMessage.ToolCall toolCall, String storedArguments, List events, String conversationId, String workspaceBasePath) { + return executePreApproved(toolCall, storedArguments, events, conversationId, + workspaceBasePath, null); + } + + /** + * RFC-052 PR-2: returnDirect-aware variant. When the pre-approved tool + * declares {@code returnDirect=true}, the result is captured into + * {@code directOutputs} (verbatim), the SSE consumer gets a + * {@code tool_direct_result} event, and the {@link ToolResponseMessage} + * carries the placeholder so any subsequent LLM call can never see the + * full payload. + * + * @param directOutputs nullable; pass-through for callers that don't track + * direct outputs (kept for legacy compatibility). + */ + public ToolResponseMessage.ToolResponse executePreApproved( + AssistantMessage.ToolCall toolCall, String storedArguments, + List events, + String conversationId, String workspaceBasePath, + List directOutputs) { String toolName = toolCall.name(); String callArguments = storedArguments != null ? storedArguments : toolCall.arguments(); @@ -350,6 +384,25 @@ public class ToolExecutionExecutor { log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName); String result = callback.call(callArguments); int rawLen = result != null ? result.length() : 0; + + // RFC-052: pre-approved tool may itself be returnDirect — in that + // case its result must take the direct path (no spill, no LLM). + // Without this branch, an approved direct tool would leak its full + // content into the next LLM round-trip via the ToolResponseMessage. + if (isReturnDirect(callback)) { + String fullResult = result != null ? result : ""; + log.info("[ToolExecutor] Pre-approved tool {} is returnDirect; bypassing " + + "spill/truncate, broadcasting tool_direct_result ({} chars)", toolName, rawLen); + if (directOutputs != null) { + directOutputs.add(new DirectToolOutput( + toolCall.id(), toolName, fullResult, System.currentTimeMillis())); + } + events.add(GraphEventPublisher.toolDirectResult( + toolCall.id(), toolName, fullResult)); + return new ToolResponseMessage.ToolResponse( + toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER); + } + // Phase 3 Layer 2: spill before truncation when storage is wired. // Use the caller-supplied conversationId so spill files inherit the // same per-conversation directory layout as the non-replay path. @@ -366,9 +419,11 @@ public class ToolExecutionExecutor { toolCall.id(), toolName, result != null ? result : ""); } catch (Exception e) { log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage()); - events.add(GraphEventPublisher.toolComplete(toolName, e.getMessage(), false)); - return new ToolResponseMessage.ToolResponse( - toolCall.id(), toolName, "Tool execution failed: " + e.getMessage()); + String safeError = isReturnDirect(callback) + ? "Tool execution failed (details withheld per returnDirect policy)" + : "Tool execution failed: " + e.getMessage(); + events.add(GraphEventPublisher.toolComplete(toolName, safeError, false)); + return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError); } } @@ -391,7 +446,8 @@ public class ToolExecutionExecutor { private void executePreparedCalls(List preparedCalls, List allResponses, - List events) { + List events, + List directOutputs) { long execStartMs = System.currentTimeMillis(); if (!preparedCalls.isEmpty() && streamTracker != null) { String conversationId = preparedCalls.get(0).conversationId; @@ -408,11 +464,11 @@ public class ToolExecutionExecutor { if (batch.size() == 1) { // 单个工具(safe 或 unsafe),直接执行 PreparedToolCall pc = batch.get(0); - ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events); + ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events, directOutputs); allResponses.set(pc.resultIndex, response); } else { // 多个 safe 工具,并行执行 - executeParallelBatch(batch, allResponses, events); + executeParallelBatch(batch, allResponses, events, directOutputs); } } @@ -456,7 +512,8 @@ public class ToolExecutionExecutor { private void executeParallelBatch(List batch, List allResponses, - List events) { + List events, + List directOutputs) { log.info("[ToolExecutor] Executing {} safe tools in parallel: {}", batch.size(), batch.stream().map(pc -> pc.toolCall.name()).toList()); long batchStartMs = System.currentTimeMillis(); @@ -464,7 +521,7 @@ public class ToolExecutionExecutor { Map> futures = new LinkedHashMap<>(); for (PreparedToolCall pc : batch) { CompletableFuture future = - CompletableFuture.supplyAsync(() -> executeSingleTool(pc, events), TOOL_EXECUTOR); + CompletableFuture.supplyAsync(() -> executeSingleTool(pc, events, directOutputs), TOOL_EXECUTOR); futures.put(pc.resultIndex, future); } @@ -495,7 +552,8 @@ public class ToolExecutionExecutor { } private ToolResponseMessage.ToolResponse executeSingleTool(PreparedToolCall pc, - List events) { + List events, + List directOutputs) { String toolName = pc.toolCall.name(); try { if (streamTracker != null) { @@ -517,6 +575,31 @@ public class ToolExecutionExecutor { } int rawLen = result != null ? result.length() : 0; + // RFC-052: returnDirect tools bypass spill / truncation / LLM context. + // Their full text goes to the user verbatim and is never persisted to + // a workspace cache file (spill could leak sensitive data). + if (isReturnDirect(pc.callback)) { + String fullResult = result != null ? result : ""; + log.info("[ToolExecutor] Tool {} is returnDirect; bypassing spill/truncate, " + + "broadcasting tool_direct_result ({} chars)", toolName, rawLen); + directOutputs.add(new DirectToolOutput( + pc.toolCall.id(), toolName, fullResult, System.currentTimeMillis())); + GraphEventPublisher.GraphEvent directEvent = + GraphEventPublisher.toolDirectResult(pc.toolCall.id(), toolName, fullResult); + events.add(directEvent); + if (streamTracker != null) { + streamTracker.broadcastObject(pc.conversationId, + GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data()); + streamTracker.updateRunningTool(pc.conversationId, null); + } + // Placeholder keeps the tool_call_id ↔ tool_response pairing valid + // for OpenAI-compatible providers, while withholding the data from + // any subsequent LLM round (the graph won't take a next round — + // see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch). + return new ToolResponseMessage.ToolResponse( + pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER); + } + // RFC-008 Phase 3 Layer 2: spill oversized results to disk and replace // with preview + path. Falls back to truncation when spilling is // disabled or fails. Spill preserves the full output (read_file can @@ -538,15 +621,22 @@ public class ToolExecutionExecutor { pc.toolCall.id(), toolName, result != null ? result : ""); } catch (Exception e) { log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e); - String normalizedError = normalizeToolExecutionError(e); - events.add(GraphEventPublisher.toolComplete(toolName, normalizedError, false)); + // RFC-052: for returnDirect tools, even the error message is + // suspect — exception text may carry stack traces, SQL fragments, + // or other sensitive substrings that should not enter LLM context. + // Emit a generic placeholder instead. Full error still goes to logs + // for operator diagnosis. + String reportedError = isReturnDirect(pc.callback) + ? "Tool execution failed (details withheld per returnDirect policy)" + : normalizeToolExecutionError(e); + events.add(GraphEventPublisher.toolComplete(toolName, reportedError, false)); if (streamTracker != null) { streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE, - GraphEventPublisher.toolComplete(toolName, normalizedError, false).data()); + GraphEventPublisher.toolComplete(toolName, reportedError, false).data()); streamTracker.updateRunningTool(pc.conversationId, null); } return new ToolResponseMessage.ToolResponse( - pc.toolCall.id(), toolName, normalizedError); + pc.toolCall.id(), toolName, reportedError); } } @@ -602,6 +692,24 @@ public class ToolExecutionExecutor { // ==================== 辅助方法 ==================== + /** + * RFC-052: a tool is "direct" when its {@link ToolCallback#getToolMetadata()} + * reports {@code returnDirect=true}. {@code @Tool(returnDirect=true)} maps + * here automatically; MCP tools rely on the + * {@code ReturnDirectMcpToolCallback} decorator to override the metadata + * (the upstream {@code SyncMcpToolCallback} returns the framework default + * of {@code false}). + */ + private static boolean isReturnDirect(ToolCallback callback) { + try { + return callback.getToolMetadata() != null + && callback.getToolMetadata().returnDirect(); + } catch (Exception e) { + log.debug("[ToolExecutor] Failed to read returnDirect metadata: {}", e.getMessage()); + return false; + } + } + /** * Returns true when the tool can run in parallel with other safe tools. * Consults the registry first (annotation-driven, populated at startup); @@ -699,6 +807,25 @@ public class ToolExecutionExecutor { /** 审批 pending ID(如果 awaitingApproval=true) */ String pendingId, /** 触发审批 barrier 的工具名(如果 awaitingApproval=true) */ - String barrierToolName - ) {} + String barrierToolName, + /** + * RFC-052: full-text outputs from any returnDirect tools that ran + * in this batch. Non-empty list ⇒ graph must short-circuit to + * FinalAnswerNode without re-entering the LLM. + */ + List directOutputs + ) { + /** Backwards-compatible constructor for callers that don't track direct outputs. */ + public ToolExecutionResult(List responses, + List events, + boolean awaitingApproval, + String pendingId, + String barrierToolName) { + this(responses, events, awaitingApproval, pendingId, barrierToolName, List.of()); + } + + public boolean hasDirectOutputs() { + return directOutputs != null && !directOutputs.isEmpty(); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index 29199cb0..18a59c61 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -84,6 +84,33 @@ public class ActionNode implements NodeAction { log.info("[ActionNode] Approval pending detected, setting AWAITING_APPROVAL=true to terminate graph"); } + // RFC-052: any returnDirect tool in this batch ⇒ short-circuit the graph. + // ObservationDispatcher will route to FinalAnswerNode (skipping the next + // LLM call). Direct outputs and the trigger flag both live in state so + // FinalAnswerNode can assemble the final answer verbatim. + // + // Priority guard: when an approval barrier ALSO fires in the same batch + // (a direct tool ran successfully BEFORE a sibling tool that needed + // approval), let the approval flow win. Otherwise the user would see a + // "RETURN_DIRECT" final answer while an approval modal is still open + // for the unresolved sibling — a confusing dual-track state. After the + // user resolves the approval, the replay path will re-execute and the + // direct tool's content reaches the user via the streamedContent path + // instead. Same-batch direct+approval is rare; we explicitly defer to + // approval for safety. + if (result.hasDirectOutputs() && !result.awaitingApproval()) { + output.returnDirectTriggered(true); + output.directToolOutputs(result.directOutputs()); + log.info("[ActionNode] RETURN_DIRECT_TRIGGERED — {} direct tool output(s), " + + "graph will route to FinalAnswerNode without re-entering LLM", + result.directOutputs().size()); + } else if (result.hasDirectOutputs() && result.awaitingApproval()) { + log.warn("[ActionNode] Mixed batch: {} direct output(s) co-occurring with approval " + + "barrier on '{}'; deferring to approval flow (RFC-052 §6.5)", + result.directOutputs().size(), + result.barrierToolName() != null ? result.barrierToolName() : "unknown"); + } + // replay 完成后清空 forced_tool_call,防止下一轮再触发 if (isReplay) { output.forcedToolCall(""); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index 0f6438c9..1e4fbb94 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -3,9 +3,11 @@ package vip.mate.agent.graph.node; import com.alibaba.cloud.ai.graph.OverAllState; import com.alibaba.cloud.ai.graph.action.NodeAction; import lombok.extern.slf4j.Slf4j; +import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.FinishReason; import vip.mate.agent.graph.state.MateClawStateAccessor; +import java.util.List; import java.util.Map; /** @@ -34,6 +36,34 @@ public class FinalAnswerNode implements NodeAction { String finalThinking; FinishReason finishReason; + // RFC-052 — RETURN_DIRECT path takes the highest priority after stopping checks. + // The full text of the direct tool result(s) becomes the final answer + // verbatim; no LLM call has been made on it. Thinking from the LLM + // call that *decided* to invoke the direct tool is preserved (it has + // already been streamed; this just keeps the state symmetric with the + // NORMAL / SUMMARIZED / LIMIT_EXCEEDED branches below). + if (accessor.returnDirectTriggered()) { + List outputs = accessor.directToolOutputs(); + if (!outputs.isEmpty()) { + String assembled = assembleDirectAnswer(outputs); + String currentThinking = accessor.currentThinking(); + String existingThinking = accessor.finalThinking(); + String preservedThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking; + log.info("[FinalAnswerNode] RETURN_DIRECT — assembled final answer from {} direct " + + "tool output(s), {} chars (thinking preserved: {} chars)", + outputs.size(), assembled.length(), preservedThinking.length()); + var builder = MateClawStateAccessor.output() + .finalAnswer(assembled) + .finishReason(FinishReason.RETURN_DIRECT); + if (!preservedThinking.isEmpty()) { + builder.finalThinking(preservedThinking); + } + return builder.build(); + } + log.warn("[FinalAnswerNode] RETURN_DIRECT_TRIGGERED=true but DIRECT_TOOL_OUTPUTS empty; " + + "falling through to default final-answer assembly"); + } + // 审批等待路径:Graph 因 AWAITING_APPROVAL 终止,保留已流式推送的内容用于持久化 if (accessor.awaitingApproval()) { String preservedContent = accessor.streamedContent(); @@ -115,6 +145,27 @@ public class FinalAnswerNode implements NodeAction { return builder.build(); } + /** + * RFC-052 §2.5: assemble the final answer from direct tool outputs. + * Single output ⇒ verbatim full text. Multiple outputs ⇒ each prefixed + * with a Markdown heading so the user can tell them apart. + */ + private static String assembleDirectAnswer(List outputs) { + if (outputs.size() == 1) { + return outputs.get(0).fullResult(); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < outputs.size(); i++) { + DirectToolOutput out = outputs.get(i); + if (i > 0) { + sb.append("\n\n"); + } + sb.append("### ").append(out.toolName()).append("\n"); + sb.append(out.fullResult()); + } + return sb.toString(); + } + private FinishReason parseFinishReason(String reason) { if (reason == null || reason.isEmpty()) { return FinishReason.NORMAL; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 680c66e3..e62ec41b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -20,6 +20,7 @@ import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.plan.state.PlanStateAccessor; import vip.mate.agent.graph.plan.state.PlanStateKeys; +import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.RuntimeContextInjector; @@ -116,6 +117,11 @@ public class StepExecutionNode implements NodeAction { int stepPromptTokens = 0; int stepCompletionTokens = 0; + // RFC-052: any returnDirect tool that fires inside this step must + // short-circuit the entire plan (not just this step). We accumulate + // outputs across the inner loop and break out as soon as one appears. + List stepDirectOutputs = new ArrayList<>(); + try { while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) { // PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay @@ -179,8 +185,12 @@ public class StepExecutionNode implements NodeAction { if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) { String storedArguments = extractArgumentsFromPayload(preApprovedPayload); events.add(GraphEventPublisher.toolStart(toolCall.name(), toolCall.arguments())); + // RFC-052: pass the directOutputs collector so that an + // approved direct tool's full content is captured here + // (instead of leaking into the next LLM round). ToolResponseMessage.ToolResponse response = executor.executePreApproved( - toolCall, storedArguments, events, conversationId, workspaceBasePath); + toolCall, storedArguments, events, conversationId, workspaceBasePath, + stepDirectOutputs); toolResponses.add(response); preApprovedPayload = ""; // 只消费一次 } else { @@ -189,6 +199,9 @@ public class StepExecutionNode implements NodeAction { List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath); toolResponses.addAll(execResult.responses()); events.addAll(execResult.events()); + if (execResult.hasDirectOutputs()) { + stepDirectOutputs.addAll(execResult.directOutputs()); + } if (execResult.awaitingApproval()) { approvalTriggered = true; approvalToolName = toolCall.name(); @@ -202,6 +215,9 @@ public class StepExecutionNode implements NodeAction { allToolCalls, conversationId, agentId, false, "", workspaceBasePath); toolResponses.addAll(execResult.responses()); events.addAll(execResult.events()); + if (execResult.hasDirectOutputs()) { + stepDirectOutputs.addAll(execResult.directOutputs()); + } if (execResult.awaitingApproval()) { approvalTriggered = true; approvalToolName = execResult.barrierToolName() != null @@ -220,6 +236,16 @@ public class StepExecutionNode implements NodeAction { if (approvalTriggered) { break; } + + // RFC-052: returnDirect short-circuit. Any direct tool in this + // step ends the plan immediately; the dispatcher routes via + // currentPhase=plan_aborted so no further LLM call happens. + if (!stepDirectOutputs.isEmpty()) { + log.info("[StepExecution] RETURN_DIRECT — step {} produced {} direct " + + "tool output(s); aborting plan execution", + stepIndex, stepDirectOutputs.size()); + break; + } } // 处理审批暂停 @@ -238,6 +264,40 @@ public class StepExecutionNode implements NodeAction { .build(); } + // RFC-052: direct tool short-circuit at the plan level. Treat the + // assembled direct text as the final summary and abort the plan; + // the dispatcher routes plan_aborted to END so no further LLM call + // is made. Persisting RETURN_DIRECT_TRIGGERED + DIRECT_TOOL_OUTPUTS + // lets the SSE accumulator pick up directToolNames metadata so + // history scrub (BaseAgent.isDirectToolMessage) kicks in next turn. + // + // Plan status is "completed" (not "failed"): the user got their + // answer correctly, the plan just terminated earlier than the + // model's planning stage anticipated. Marking as failed would skew + // operational dashboards and confuse plan-history readers. + if (!stepDirectOutputs.isEmpty()) { + String assembled = assembleDirectAnswerText(stepDirectOutputs); + planningService.updateSubPlanResult(planId, stepIndex, assembled); + planningService.completePlan(planId, + "Plan completed via returnDirect tool: " + + stepDirectOutputs.get(0).toolName()); + events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled)); + return PlanStateAccessor.output() + .currentStepResult(assembled) + .currentStepIndex(steps.size()) // 越界 → dispatcher 收束 + .currentPhase("plan_aborted") + .finalSummary(assembled) + .contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送 + .put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true) + .put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs)) + .put(MateClawStateKeys.PROMPT_TOKENS, + state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens) + .put(MateClawStateKeys.COMPLETION_TOKENS, + state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens) + .events(events) + .build(); + } + if (finalResult == null) { finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)"; log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex); @@ -298,6 +358,26 @@ public class StepExecutionNode implements NodeAction { .build(); } + /** + * RFC-052: assemble the final answer text from direct tool outputs in this + * step. Mirrors {@code FinalAnswerNode#assembleDirectAnswer} so the user + * sees the same shape regardless of which graph (ReAct / Plan-Execute) + * produced the answer. + */ + private static String assembleDirectAnswerText(List outputs) { + if (outputs.size() == 1) { + return outputs.get(0).fullResult(); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < outputs.size(); i++) { + DirectToolOutput out = outputs.get(i); + if (i > 0) sb.append("\n\n"); + sb.append("### ").append(out.toolName()).append("\n"); + sb.append(out.fullResult()); + } + return sb.toString(); + } + private List buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) { List messages = new ArrayList<>(); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/DirectToolOutput.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/DirectToolOutput.java new file mode 100644 index 00000000..116a028c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/DirectToolOutput.java @@ -0,0 +1,24 @@ +package vip.mate.agent.graph.state; + +/** + * RFC-052: full-text result of a tool call that declared {@code returnDirect=true}. + * + *

The result is delivered to the user (and persisted to {@code mate_message}) + * verbatim, but is intentionally not placed into any subsequent LLM + * prompt — see {@link MateClawStateKeys#DIRECT_TOOL_OUTPUTS} and + * {@code FinalAnswerNode}'s direct branch. + * + * @param toolCallId the tool call id from the originating LLM response + * @param toolName the resolved tool name + * @param fullResult the complete tool result, never truncated or spilled + * @param executedAtMs epoch milliseconds when the tool returned + * + * @author MateClaw Team + */ +public record DirectToolOutput( + String toolCallId, + String toolName, + String fullResult, + long executedAtMs +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java index aa9b09e7..f61c1379 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java @@ -20,7 +20,11 @@ public enum FinishReason { ERROR_FALLBACK("error_fallback"), /** 用户主动停止 */ - STOPPED("stopped"); + STOPPED("stopped"), + + /** RFC-052: a tool with returnDirect=true short-circuited the loop; + * result was delivered to the user without re-entering the LLM. */ + RETURN_DIRECT("return_direct"); private final String value; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index 03254e4b..92b1bce3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -195,6 +195,17 @@ public final class MateClawStateAccessor { return state.value(AWAITING_APPROVAL, false); } + // ===== RFC-052: returnDirect ===== + + public boolean returnDirectTriggered() { + return state.value(RETURN_DIRECT_TRIGGERED, false); + } + + @SuppressWarnings("unchecked") + public List directToolOutputs() { + return state.>value(DIRECT_TOOL_OUTPUTS).orElse(List.of()); + } + // ===== 审批重放 ===== public String forcedToolCall() { @@ -373,6 +384,15 @@ public final class MateClawStateAccessor { return put(AWAITING_APPROVAL, awaiting); } + // ---- RFC-052: returnDirect ---- + public OutputBuilder returnDirectTriggered(boolean triggered) { + return put(RETURN_DIRECT_TRIGGERED, triggered); + } + + public OutputBuilder directToolOutputs(List outputs) { + return put(DIRECT_TOOL_OUTPUTS, outputs); + } + // ---- 审批重放 ---- public OutputBuilder forcedToolCall(String json) { return put(FORCED_TOOL_CALL, json); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index 493f30f2..46e084f2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -140,4 +140,19 @@ public final class MateClawStateKeys { // ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)===== public static final String RUNTIME_MODEL_NAME = "runtime_model_name"; public static final String RUNTIME_PROVIDER_ID = "runtime_provider_id"; + + // ===== RFC-052: Tool returnDirect 与数据隔离 ===== + + /** + * RFC-052: when true the latest tool batch contained at least one tool + * declared as returnDirect, so the graph must short-circuit to + * {@link #FINAL_ANSWER_NODE} without re-entering the LLM. + */ + public static final String RETURN_DIRECT_TRIGGERED = "return_direct_triggered"; + + /** + * RFC-052: list of {@code DirectToolOutput} accumulated from the most recent + * tool batch, used by FinalAnswerNode to assemble the final answer. + */ + public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs"; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 682d2e95..3bd30743 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1238,6 +1238,8 @@ public class ChatController { private final List> browserActions = new ArrayList<>(); private final List warnings = new ArrayList<>(); private final List> planStepResults = new ArrayList<>(); + /** RFC-052: tool names whose returnDirect output was folded into the assistant message */ + private final List directToolNames = new ArrayList<>(); private int segCounter = 0; private int promptTokens = 0; private int completionTokens = 0; @@ -1390,6 +1392,18 @@ public class ChatController { seg.put("toolName", data.getOrDefault("toolName", "")); seg.put("toolArgs", data.getOrDefault("arguments", "")); segments.add(seg); + } else if ("tool_direct_result".equals(eventType)) { + // RFC-052: returnDirect tool — track the tool name so history + // replay can render a "data returned directly by tool" badge. + // The actual textual content reaches the user/persistence layer + // through the regular content_delta path (FinalAnswerNode's + // FINAL_ANSWER → StateGraphReActAgent → StreamDelta), so we + // intentionally do NOT add a content-bearing segment here to + // avoid the user seeing the same text twice. + String toolName = String.valueOf(data.getOrDefault("toolName", "")); + if (!toolName.isBlank() && !directToolNames.contains(toolName)) { + directToolNames.add(toolName); + } } else if ("tool_call_completed".equals(eventType)) { String toolName = String.valueOf(data.getOrDefault("toolName", "")); // toolCalls(兼容) @@ -1525,6 +1539,13 @@ public class ChatController { if (!warnings.isEmpty()) { metadata.put("warnings", warnings); } + if (!directToolNames.isEmpty()) { + // RFC-052 §3.3: only the tool names go into metadata — + // the full content already lives in mate_message.content + // (assembled by FinalAnswerNode). UI uses this to badge + // historical messages as "data returned directly by tool". + metadata.put("directToolNames", directToolNames); + } return objectMapper.writeValueAsString(metadata); } catch (Exception e) { log.warn("Failed to serialize metadata: {}", e.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java new file mode 100644 index 00000000..6f83f4b9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpReturnDirectProperties.java @@ -0,0 +1,52 @@ +package vip.mate.tool.mcp.runtime; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * RFC-052 §3.4 / PR-4: MCP tool return-direct opt-in list. + * + *

Tools listed here are wrapped in {@link ReturnDirectMcpToolCallback} so + * their results bypass the LLM context (see {@code ToolExecutionExecutor} and + * {@code ObservationDispatcher} for the routing). + * + *

Configuration ({@code application.yml}): + *

+ * mateclaw:
+ *   mcp:
+ *     return-direct:
+ *       tools:
+ *         - query_employee_salary
+ *         - read_medical_record
+ * 
+ * + *

Match is by tool name only (matching the upstream {@code ToolDefinition.name()}). + * Per-server scoping is intentionally out of scope for the first iteration; if + * the same tool name comes from two servers and only one should be direct, give + * one of them a name prefix at the MCP server config layer. + * + * @author MateClaw Team + */ +@Component +@ConfigurationProperties(prefix = "mateclaw.mcp.return-direct") +public class McpReturnDirectProperties { + + /** Tool names that should be treated as returnDirect. */ + private Set tools = Collections.emptySet(); + + public Set getTools() { + return tools; + } + + public void setTools(Set tools) { + this.tools = tools != null ? new LinkedHashSet<>(tools) : Collections.emptySet(); + } + + public boolean isReturnDirect(String toolName) { + return toolName != null && tools.contains(toolName); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java index 3a00897b..4a8496ea 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpToolCallbackProvider.java @@ -6,6 +6,9 @@ import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; + /** * MCP 工具回调提供者 *

@@ -15,6 +18,9 @@ import org.springframework.stereotype.Component; * 每次调用 getToolCallbacks() 都会从 McpClientManager 获取最新的 active tools, * 因此新增/删除 MCP server 后无需重启即可生效。 * + *

RFC-052: tools listed in {@link McpReturnDirectProperties} are wrapped in + * {@link ReturnDirectMcpToolCallback} so their results bypass the LLM context. + * * @author MateClaw Team */ @Slf4j @@ -23,6 +29,7 @@ import org.springframework.stereotype.Component; public class McpToolCallbackProvider implements ToolCallbackProvider { private final McpClientManager mcpClientManager; + private final McpReturnDirectProperties returnDirectProperties; @Override public ToolCallback[] getToolCallbacks() { @@ -32,7 +39,21 @@ public class McpToolCallbackProvider implements ToolCallbackProvider { log.debug("McpToolCallbackProvider providing {} tools from {} active MCP servers", callbacks.size(), mcpClientManager.getActiveCount()); } - return callbacks.toArray(new ToolCallback[0]); + + // RFC-052: opt-in returnDirect wrapping. The decorator only changes + // ToolMetadata.returnDirect(); guard/approval/observability still + // see the original callback through the wrapper. + List wrapped = new ArrayList<>(callbacks.size()); + for (ToolCallback cb : callbacks) { + String name = cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null; + if (returnDirectProperties.isReturnDirect(name)) { + log.info("[McpToolCallbackProvider] wrapping MCP tool '{}' as returnDirect (RFC-052)", name); + wrapped.add(new ReturnDirectMcpToolCallback(cb)); + } else { + wrapped.add(cb); + } + } + return wrapped.toArray(new ToolCallback[0]); } catch (Exception e) { log.warn("Failed to collect MCP tool callbacks: {}", e.getMessage()); return new ToolCallback[0]; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java new file mode 100644 index 00000000..e321aa3f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallback.java @@ -0,0 +1,62 @@ +package vip.mate.tool.mcp.runtime; + +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +/** + * RFC-052 §3.4: thin decorator that overrides {@link ToolCallback#getToolMetadata()} + * to report {@code returnDirect=true} for MCP tools. + * + *

Spring AI 1.1.4's {@code SyncMcpToolCallback} / {@code AsyncMcpToolCallback} + * never override {@code getToolMetadata()} (they inherit the framework default + * which yields {@code returnDirect=false}), and the upstream MCP protocol layer + * has no equivalent field. So MateClaw must wrap MCP callbacks at registration + * time when their server+tool config opts in via + * {@code mateclaw.mcp.return-direct.tools}. + * + *

Everything else (definition, invocation, exceptions) is delegated verbatim + * — guard, approval, observability, audit all see the original callback. + * + * @author MateClaw Team + */ +public final class ReturnDirectMcpToolCallback implements ToolCallback { + + private static final ToolMetadata RETURN_DIRECT_METADATA = + ToolMetadata.builder().returnDirect(true).build(); + + private final ToolCallback delegate; + + public ReturnDirectMcpToolCallback(ToolCallback delegate) { + if (delegate == null) { + throw new IllegalArgumentException("delegate must not be null"); + } + this.delegate = delegate; + } + + @Override + public ToolDefinition getToolDefinition() { + return delegate.getToolDefinition(); + } + + @Override + public ToolMetadata getToolMetadata() { + return RETURN_DIRECT_METADATA; + } + + @Override + public String call(String arguments) { + return delegate.call(arguments); + } + + @Override + public String call(String arguments, ToolContext toolContext) { + return delegate.call(arguments, toolContext); + } + + /** Test/diagnostic accessor — not part of the framework contract. */ + public ToolCallback getDelegate() { + return delegate; + } +}