mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(tool): tool returnDirect and sensitive-data quarantine
This commit is contained in:
parent
c13d9b4c88
commit
4a95e7dfe4
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {}, " +
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<>();
|
||||
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user