feat(tool): tool returnDirect and sensitive-data quarantine

This commit is contained in:
matevip 2026-04-25 19:02:35 +08:00
parent c13d9b4c88
commit 4a95e7dfe4
15 changed files with 702 additions and 27 deletions

View File

@ -217,13 +217,7 @@ public abstract class BaseAgent {
List<Message> 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.
*
* <p>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.
*
* <p>Current stages (in order):
* <ol>
* <li><b>Drop approval placeholders</b> assistant messages whose
* content is a "[等待审批]" stub from the approval flow are removed
* entirely so they don't pollute the LLM context.</li>
* <li><b>Render content</b> convert {@code MessageEntity} to a string
* via {@link ConversationService#renderMessageContent}.</li>
* <li><b>Direct-tool scrub (RFC-052)</b> assistant messages produced
* by a returnDirect tool path get their content replaced with a
* tool-named placeholder; the original DB content is unchanged.</li>
* <li><b>Type dispatch</b> wrap into {@code AssistantMessage},
* {@code SystemMessage}, or {@code UserMessage} (with multimodal
* Media for image/video parts).</li>
* </ol>
*/
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.
*
* <p>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.
*
* <p>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<String> 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<String> 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}.
*
* <p>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<String> 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<String> 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);

View File

@ -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<String, Object> 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<String, Object> metrics) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.HashMap<>(metrics);

View File

@ -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 {}, " +

View File

@ -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<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> 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<DirectToolOutput> 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<GraphEventPublisher.GraphEvent> 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<GraphEventPublisher.GraphEvent> events,
String conversationId, String workspaceBasePath,
List<DirectToolOutput> 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<PreparedToolCall> preparedCalls,
List<ToolResponseMessage.ToolResponse> allResponses,
List<GraphEventPublisher.GraphEvent> events) {
List<GraphEventPublisher.GraphEvent> events,
List<DirectToolOutput> 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<PreparedToolCall> batch,
List<ToolResponseMessage.ToolResponse> allResponses,
List<GraphEventPublisher.GraphEvent> events) {
List<GraphEventPublisher.GraphEvent> events,
List<DirectToolOutput> 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<Integer, CompletableFuture<ToolResponseMessage.ToolResponse>> futures = new LinkedHashMap<>();
for (PreparedToolCall pc : batch) {
CompletableFuture<ToolResponseMessage.ToolResponse> 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<GraphEventPublisher.GraphEvent> events) {
List<GraphEventPublisher.GraphEvent> events,
List<DirectToolOutput> 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<DirectToolOutput> directOutputs
) {
/** Backwards-compatible constructor for callers that don't track direct outputs. */
public ToolExecutionResult(List<ToolResponseMessage.ToolResponse> responses,
List<GraphEventPublisher.GraphEvent> events,
boolean awaitingApproval,
String pendingId,
String barrierToolName) {
this(responses, events, awaitingApproval, pendingId, barrierToolName, List.of());
}
public boolean hasDirectOutputs() {
return directOutputs != null && !directOutputs.isEmpty();
}
}
}

View File

@ -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("");

View File

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

View File

@ -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<DirectToolOutput> 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<DirectToolOutput> 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<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt, String workspaceBasePath) {
List<Message> messages = new ArrayList<>();

View File

@ -0,0 +1,24 @@
package vip.mate.agent.graph.state;
/**
* RFC-052: full-text result of a tool call that declared {@code returnDirect=true}.
*
* <p>The result is delivered to the user (and persisted to {@code mate_message})
* verbatim, but is intentionally <em>not</em> 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
) {
}

View File

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

View File

@ -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<DirectToolOutput> directToolOutputs() {
return state.<List<DirectToolOutput>>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<DirectToolOutput> outputs) {
return put(DIRECT_TOOL_OUTPUTS, outputs);
}
// ---- 审批重放 ----
public OutputBuilder forcedToolCall(String json) {
return put(FORCED_TOOL_CALL, json);

View File

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

View File

@ -1238,6 +1238,8 @@ public class ChatController {
private final List<Map<String, Object>> browserActions = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** RFC-052: tool names whose returnDirect output was folded into the assistant message */
private final List<String> 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());

View File

@ -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.
*
* <p>Tools listed here are wrapped in {@link ReturnDirectMcpToolCallback} so
* their results bypass the LLM context (see {@code ToolExecutionExecutor} and
* {@code ObservationDispatcher} for the routing).
*
* <p>Configuration ({@code application.yml}):
* <pre>
* mateclaw:
* mcp:
* return-direct:
* tools:
* - query_employee_salary
* - read_medical_record
* </pre>
*
* <p>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<String> tools = Collections.emptySet();
public Set<String> getTools() {
return tools;
}
public void setTools(Set<String> tools) {
this.tools = tools != null ? new LinkedHashSet<>(tools) : Collections.emptySet();
}
public boolean isReturnDirect(String toolName) {
return toolName != null && tools.contains(toolName);
}
}

View File

@ -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 工具回调提供者
* <p>
@ -15,6 +18,9 @@ import org.springframework.stereotype.Component;
* 每次调用 getToolCallbacks() 都会从 McpClientManager 获取最新的 active tools
* 因此新增/删除 MCP server 后无需重启即可生效
*
* <p>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<ToolCallback> 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];

View File

@ -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.
*
* <p>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}.
*
* <p>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;
}
}