From 071fff76abaeefe224c7cdfd9569c048696bc9d2 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 11 Aug 2026 05:31:44 -0400 Subject: [PATCH] fix(agent): enforce tool-backed action completion --- .../vip/mate/agent/AgentGraphBuilder.java | 11 ++- .../agent/graph/StateGraphReActAgent.java | 10 ++- .../agent/graph/edge/ReasoningDispatcher.java | 5 ++ .../graph/guard/ActionCompletionPolicy.java | 23 +++++ .../vip/mate/agent/graph/node/ActionNode.java | 58 ++++++++++++- .../mate/agent/graph/node/ReasoningNode.java | 54 +++++++++++- .../graph/state/ActionExecutionLedger.java | 85 +++++++++++++++++++ .../mate/agent/graph/state/FinishReason.java | 6 ++ .../graph/state/MateClawStateAccessor.java | 32 +++++++ .../agent/graph/state/MateClawStateKeys.java | 12 +++ .../channel/web/AgentStreamAccumulator.java | 15 +++- .../agent/BaseAgentToolCallReplayTest.java | 5 +- .../graph/edge/ReasoningDispatcherTest.java | 12 +++ .../guard/ActionCompletionPolicyTest.java | 56 ++++++++++++ .../node/ActionNodeAutoRecordSkipTest.java | 16 ++++ .../graph/node/ActionNodeLoadSkillTest.java | 31 +++++++ .../graph/node/ReasoningNodeOutputTest.java | 69 +++++++++++++++ .../state/ActionExecutionLedgerTest.java | 59 +++++++++++++ .../web/AgentStreamAccumulatorKindTest.java | 20 +++++ 19 files changed, 565 insertions(+), 14 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ActionCompletionPolicy.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ActionCompletionPolicyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 903842bc..0e0c7edf 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -777,6 +777,10 @@ public class AgentGraphBuilder { // 丢这个键,evidence_insufficient 检查会"静默地不生效" —— // StateKeyRegistrationCoverageTest 专门兜这条。 .addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE) // Multimodal sidecar routing decision for the current turn. .addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE) // RFC 48 — persistent goal state keys must be registered in @@ -1126,6 +1130,10 @@ public class AgentGraphBuilder { // 丢这个键,evidence_insufficient 检查会"静默地不生效" —— // StateKeyRegistrationCoverageTest 专门兜这条。 .addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE) // Multimodal sidecar routing decision for the current turn. .addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE) // RFC 48 — persistent goal state keys must be registered in @@ -1178,7 +1186,8 @@ public class AgentGraphBuilder { .addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE) .addConditionalEdges(MateClawStateKeys.REASONING_NODE, AsyncEdgeAction.edge_async(new ReasoningDispatcher()), - Map.of(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE, + Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE, + MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE, MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE)) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 7e3289b3..d6b23486 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -309,9 +309,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC String streamed = output.state().value(STREAMED_CONTENT).orElse(""); if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); + boolean completionRetry = output.state().value(CONTINUE_REASONING, false); addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, - output.state().value(NEEDS_TOOL_CALL, false), - output.state().value(TOOL_CALL_COUNT, 0), + completionRetry || output.state().value(NEEDS_TOOL_CALL, false), + completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), streamed)); } @@ -501,9 +502,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC String streamed = output.state().value(STREAMED_CONTENT).orElse(""); if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); + boolean completionRetry = output.state().value(CONTINUE_REASONING, false); addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, - output.state().value(NEEDS_TOOL_CALL, false), - output.state().value(TOOL_CALL_COUNT, 0), + completionRetry || output.state().value(NEEDS_TOOL_CALL, false), + completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), streamed)); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java index ee4cff5e..7e7d6cd7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/ReasoningDispatcher.java @@ -41,6 +41,11 @@ public class ReasoningDispatcher implements EdgeAction { return LIMIT_EXCEEDED_NODE; } + if (accessor.continueReasoning()) { + log.info("[ReasoningDispatcher] Completion gate requested another reasoning pass"); + return REASONING_NODE; + } + // 2. 可直接回答 → finalAnswerNode // 覆盖以下场景: // - LLM 正常产出最终回答 (needsToolCall=false, finalAnswer 非空) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ActionCompletionPolicy.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ActionCompletionPolicy.java new file mode 100644 index 00000000..610b4162 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ActionCompletionPolicy.java @@ -0,0 +1,23 @@ +package vip.mate.agent.graph.guard; + +import vip.mate.agent.graph.state.ActionExecutionLedger; + +/** Pure completion decision for action-required ReAct turns. */ +public final class ActionCompletionPolicy { + + public static final int MAX_RETRIES = 1; + + public enum Decision { ALLOW, RETRY, UNVERIFIED, FAILED } + + private ActionCompletionPolicy() { + } + + public static Decision evaluate(boolean actionRequired, int retryCount, + ActionExecutionLedger ledger) { + if (!actionRequired) return Decision.ALLOW; + ActionExecutionLedger evidence = ledger != null ? ledger : ActionExecutionLedger.empty(); + if (evidence.hasSuccessfulSubstantiveCall()) return Decision.ALLOW; + if (evidence.hasSubstantiveAttempt()) return Decision.FAILED; + return retryCount < MAX_RETRIES ? Decision.RETRY : Decision.UNVERIFIED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index 8cf3e03a..66949b6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -12,6 +12,7 @@ import vip.mate.agent.graph.executor.ToolExecutionExecutor; import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.agent.graph.state.SourceEvidenceLedger; +import vip.mate.agent.graph.state.ActionExecutionLedger; import java.util.*; import java.util.concurrent.CancellationException; @@ -152,12 +153,14 @@ public class ActionNode implements NodeAction { SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null ? result.rawEvidenceLedger() : SourceEvidenceLedger.empty(); + ActionExecutionLedger actionLedger = ActionExecutionLedger.fromEvents(result.events()); MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output() .toolResults(result.responses()) .messages(List.of((Message) toolResponseMessage)) .currentPhase("action") .events(result.events()) - .sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger)); + .sourceEvidenceLedger(accessor.sourceEvidenceLedger().merge(rawLedger)) + .actionExecutionLedger(accessor.actionExecutionLedger().merge(actionLedger)); if (result.awaitingApproval()) { output.awaitingApproval(true); @@ -195,6 +198,10 @@ public class ActionNode implements NodeAction { // and pin them into the ProgressLedger so they survive context // compression and stay visible on every turn. pinSkillConstraints(conversationId, requestedSkills); + if (actionLedger.hasSuccessfulTool(LOAD_SKILL_TOOL) + && loadedSkillsRequireAction(conversationId, requestedSkills)) { + output.actionCompletionRequired(true); + } } // Same mechanism for enable_tool @@ -210,7 +217,7 @@ public class ActionNode implements NodeAction { // sees what it already did even if it forgot to call progress_update. // Skips meta-tools (load_skill, enable_tool, progress_update) and // doesn't overwrite LLM-authored entries. - autoRecordToolCalls(conversationId, result.responses()); + autoRecordToolCalls(conversationId, result.responses(), actionLedger); return output.build(); } @@ -264,6 +271,41 @@ public class ActionNode implements NodeAction { } } + private boolean loadedSkillsRequireAction(String conversationId, Set skillNames) { + if (skillRuntimeService == null) return false; + Long workspaceId = executor.workspaceIdForConversation(conversationId); + for (String skillName : skillNames) { + try { + vip.mate.skill.runtime.model.ResolvedSkill skill = + skillRuntimeService.findActiveSkill(skillName, workspaceId); + if (resolvedSkillRequiresActionCompletion(skill)) { + return true; + } + } catch (Exception e) { + log.debug("[ActionNode] Could not inspect action contract for skill '{}': {}", + skillName, e.getMessage()); + } + } + return false; + } + + static boolean manifestRequiresActionCompletion(vip.mate.skill.manifest.SkillManifest manifest) { + if (manifest == null) return false; + String type = manifest.getType(); + if (type != null && Set.of("mcp", "acp", "code").contains(type.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + return (manifest.getAllowedTools() != null && !manifest.getAllowedTools().isEmpty()) + || (manifest.getScripts() != null && !manifest.getScripts().isEmpty()); + } + + static boolean resolvedSkillRequiresActionCompletion( + vip.mate.skill.runtime.model.ResolvedSkill skill) { + if (skill == null) return false; + return manifestRequiresActionCompletion(skill.getManifest()) + || (skill.getScripts() != null && !skill.getScripts().isEmpty()); + } + // ==================== B5: Auto-record tool calls ==================== /** @@ -278,6 +320,12 @@ public class ActionNode implements NodeAction { */ void autoRecordToolCalls(String conversationId, List responses) { + autoRecordToolCalls(conversationId, responses, null); + } + + void autoRecordToolCalls(String conversationId, + List responses, + ActionExecutionLedger executionLedger) { if (progressLedgerService == null || conversationId == null || conversationId.isBlank() || responses == null || responses.isEmpty()) { return; @@ -286,6 +334,12 @@ public class ActionNode implements NodeAction { // N separate lock+load+save cycles when the LLM calls tools in parallel. List batch = new java.util.ArrayList<>(); for (ToolResponseMessage.ToolResponse resp : responses) { + if (executionLedger != null) { + ActionExecutionLedger.Receipt receipt = executionLedger.receipts().get(resp.id()); + if (receipt == null || receipt.status() != ActionExecutionLedger.Status.SUCCEEDED) { + continue; + } + } String toolName = resp.name(); if (toolName == null || toolName.isBlank() || AUTO_RECORD_SKIP.contains(toolName)) { continue; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index f602ac0b..92b4e514 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -30,6 +30,7 @@ import vip.mate.agent.graph.state.FinishReason; import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.agent.graph.state.SourceEvidenceLedger; +import vip.mate.agent.graph.guard.ActionCompletionPolicy; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.team.service.TeamContextBuilder; @@ -59,7 +60,7 @@ public class ReasoningNode implements NodeAction { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static MateClawStateAccessor.OutputBuilder reasonOutput() { - return MateClawStateAccessor.output(); + return MateClawStateAccessor.output().continueReasoning(false); } /** @@ -1166,6 +1167,57 @@ public class ReasoningNode implements NodeAction { .build(); } else { String content = result.text(); + ActionCompletionPolicy.Decision completionDecision = ActionCompletionPolicy.evaluate( + accessor.actionCompletionRequired(), accessor.actionCompletionRetryCount(), + accessor.actionExecutionLedger()); + if (completionDecision == ActionCompletionPolicy.Decision.RETRY) { + log.warn("[ReasoningNode] Rejecting text-only action completion; continuing once"); + UserMessage continuation = new UserMessage(""" + [Runtime completion gate] + This turn requires a real tool-backed action, but no substantive tool call was observed. + Continue now by emitting the required tool call. Do not claim success or only describe the call. + """); + return reasonOutput() + .continueReasoning(true) + .actionCompletionRetryCount(accessor.actionCompletionRetryCount() + 1) + .needsToolCall(false) + .shouldSummarize(false) + .finalAnswer("") + .clearFinishReason() + .messages(List.of((Message) result.assistantMessage(), continuation)) + .currentPhase("reasoning") + .streamedContent(content != null ? content : "") + .streamedThinking(result.thinking()) + .contentStreamed(true) + .thinkingStreamed(!result.thinking().isEmpty()) + .llmCallCount(nextLlmCallCount) + .mergeUsage(state, result) + .events(buildEvents(phaseEvent, iterStartEvent)) + .build(); + } + if (completionDecision == ActionCompletionPolicy.Decision.UNVERIFIED + || completionDecision == ActionCompletionPolicy.Decision.FAILED) { + boolean failed = completionDecision == ActionCompletionPolicy.Decision.FAILED; + String guardedAnswer = failed + ? "动作工具执行失败,未确认操作成功。请检查工具返回的错误后重试。" + : "未观察到实际的动作工具调用,因此没有执行或确认该操作。请重试。"; + log.warn("[ReasoningNode] Blocking unsupported action completion: {}", completionDecision); + return reasonOutput() + .needsToolCall(false) + .shouldSummarize(false) + .finalAnswer(guardedAnswer) + .finalThinking(result.thinking()) + .messages(List.of((Message) result.assistantMessage())) + .currentPhase("reasoning") + .streamedContent("") + .finishReason(failed ? FinishReason.ACTION_FAILED : FinishReason.ACTION_UNVERIFIED) + .contentStreamed(false) + .thinkingStreamed(!result.thinking().isEmpty()) + .llmCallCount(nextLlmCallCount) + .mergeUsage(state, result) + .events(buildEvents(phaseEvent, iterStartEvent)) + .build(); + } log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0); pushPhase(conversationId, "drafting_answer", Map.of( "iteration", accessor.iterationCount(), diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java new file mode 100644 index 00000000..c2e142e2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java @@ -0,0 +1,85 @@ +package vip.mate.agent.graph.state; + +import vip.mate.agent.GraphEventPublisher; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Authoritative per-run tool completion receipts used by the action completion gate. */ +public final class ActionExecutionLedger { + + private static final int MAX_RESULT_SUMMARY_CHARS = 512; + private static final Set NON_SUBSTANTIVE_TOOLS = Set.of( + "load_skill", "enable_tool", "tool_search", "tool_describe", + "progress_update", "get_progress"); + + public enum Status { SUCCEEDED, FAILED } + + public record Receipt(String toolCallId, String toolName, Status status, + String resultSummary, long completedAt) { + public boolean substantive() { + return toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName); + } + } + + private static final ActionExecutionLedger EMPTY = new ActionExecutionLedger(Map.of()); + + private final Map receipts; + + private ActionExecutionLedger(Map receipts) { + this.receipts = Map.copyOf(receipts); + } + + public static ActionExecutionLedger empty() { + return EMPTY; + } + + public static ActionExecutionLedger fromEvents(List events) { + if (events == null || events.isEmpty()) return empty(); + Map receipts = new LinkedHashMap<>(); + int legacyIndex = 0; + for (GraphEventPublisher.GraphEvent event : events) { + if (event == null || !GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())) continue; + Map data = event.data(); + String id = String.valueOf(data.getOrDefault("toolCallId", "")); + String name = String.valueOf(data.getOrDefault("toolName", "")); + if (id.isBlank()) id = "legacy-" + name + "-" + legacyIndex++; + boolean success = Boolean.parseBoolean(String.valueOf(data.getOrDefault("success", false))); + String result = String.valueOf(data.getOrDefault("result", "")); + if (result.length() > MAX_RESULT_SUMMARY_CHARS) { + result = result.substring(0, MAX_RESULT_SUMMARY_CHARS) + "..."; + } + receipts.put(id, new Receipt(id, name, + success ? Status.SUCCEEDED : Status.FAILED, result, event.timestamp())); + } + return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts); + } + + public Map receipts() { + return receipts; + } + + public boolean hasSubstantiveAttempt() { + return receipts.values().stream().anyMatch(Receipt::substantive); + } + + public boolean hasSuccessfulSubstantiveCall() { + return receipts.values().stream() + .anyMatch(receipt -> receipt.substantive() && receipt.status() == Status.SUCCEEDED); + } + + public boolean hasSuccessfulTool(String toolName) { + return receipts.values().stream().anyMatch(receipt -> + receipt.status() == Status.SUCCEEDED && receipt.toolName().equals(toolName)); + } + + public ActionExecutionLedger merge(ActionExecutionLedger other) { + if (other == null || other.receipts.isEmpty()) return this; + if (receipts.isEmpty()) return other; + Map merged = new LinkedHashMap<>(receipts); + merged.putAll(other.receipts); + return new ActionExecutionLedger(merged); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java index bb66653f..2512acae 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/FinishReason.java @@ -25,6 +25,12 @@ public enum FinishReason { /** 最终回答引用了未被工具结果验证的源码事实 */ EVIDENCE_INSUFFICIENT("evidence_insufficient"), + /** An executable action was required but no substantive tool call was observed. */ + ACTION_UNVERIFIED("action_unverified"), + + /** Substantive action tools ran, but none completed successfully. */ + ACTION_FAILED("action_failed"), + /** 用户主动停止 */ STOPPED("stopped"), diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index e1fa44a7..bb8c2082 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -216,6 +216,22 @@ public final class MateClawStateAccessor { return state.value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty()); } + public ActionExecutionLedger actionExecutionLedger() { + return state.value(ACTION_EXECUTION_LEDGER).orElse(ActionExecutionLedger.empty()); + } + + public boolean actionCompletionRequired() { + return state.value(ACTION_COMPLETION_REQUIRED, false); + } + + public int actionCompletionRetryCount() { + return state.value(ACTION_COMPLETION_RETRY_COUNT, 0); + } + + public boolean continueReasoning() { + return state.value(CONTINUE_REASONING, false); + } + // ===== 审批重放 ===== public String forcedToolCall() { @@ -523,6 +539,22 @@ public final class MateClawStateAccessor { return put(SOURCE_EVIDENCE_LEDGER, ledger); } + public OutputBuilder actionExecutionLedger(ActionExecutionLedger ledger) { + return put(ACTION_EXECUTION_LEDGER, ledger); + } + + public OutputBuilder actionCompletionRequired(boolean required) { + return put(ACTION_COMPLETION_REQUIRED, required); + } + + public OutputBuilder actionCompletionRetryCount(int count) { + return put(ACTION_COMPLETION_RETRY_COUNT, count); + } + + public OutputBuilder continueReasoning(boolean shouldContinue) { + return put(CONTINUE_REASONING, shouldContinue); + } + // ---- 审批重放 ---- public OutputBuilder forcedToolCall(String json) { return put(FORCED_TOOL_CALL, json); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index db5d33f2..6c5e9743 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -184,6 +184,18 @@ public final class MateClawStateKeys { /** Source references observed from successful tool results during this run. */ public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger"; + /** Authoritative terminal tool receipts accumulated during the current graph run. */ + public static final String ACTION_EXECUTION_LEDGER = "action_execution_ledger"; + + /** True when structured runtime context says this turn must perform an executable action. */ + public static final String ACTION_COMPLETION_REQUIRED = "action_completion_required"; + + /** Number of completion-gate continuations consumed in the current run. */ + public static final String ACTION_COMPLETION_RETRY_COUNT = "action_completion_retry_count"; + + /** One-shot edge signal routing a rejected final candidate back to ReasoningNode. */ + public static final String CONTINUE_REASONING = "continue_reasoning"; + // ===== Persistent goal — cross-turn objective lock-in ===== /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java index 117b6055..cd200363 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java @@ -476,9 +476,16 @@ public final class AgentStreamAccumulator { return parts; } - private void finalizeToolCalls() { + private void interruptUnfinishedToolCalls() { for (Map tc : toolCalls) { - if ("running".equals(tc.get("status"))) tc.put("status", "completed"); + if ("running".equals(tc.get("status"))) tc.put("status", "interrupted"); + } + for (Map segment : segments) { + if ("tool_call".equals(segment.get("type")) + && "running".equals(segment.get("status"))) { + segment.put("status", "interrupted"); + segment.put("endTimestamp", System.currentTimeMillis()); + } } } @@ -487,8 +494,8 @@ public final class AgentStreamAccumulator { * toolCalls 保留兼容旧 UI,segments 是按事件顺序的完整时间线。 */ public synchronized String toMetadataJson() { - finalizeToolCalls(); - finalizeRunningSegments("thinking", "content", "tool_call"); + interruptUnfinishedToolCalls(); + finalizeRunningSegments("thinking", "content"); // Producer-tagged timelines use the kind-driven authority; untagged // ones (pre-tag producers, replayed legacy turns) keep the structural // scan as fallback. diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java index d0a7cf08..0804b465 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java @@ -65,14 +65,15 @@ class BaseAgentToolCallReplayTest { } @Test - @DisplayName("running / awaiting_approval entries are skipped — replaying them produces orphan tool_call_ids") + @DisplayName("non-terminal entries are skipped — replaying them produces orphan tool_call_ids") void incompleteToolCalls_dropped() { MessageEntity msg = new MessageEntity(); msg.setRole("assistant"); msg.setMetadata("{\"toolCalls\":[" + "{\"toolCallId\":\"call_a\",\"name\":\"a\",\"status\":\"completed\",\"result\":\"ok\"}," + "{\"toolCallId\":\"call_b\",\"name\":\"b\",\"status\":\"running\"}," - + "{\"toolCallId\":\"call_c\",\"name\":\"c\",\"status\":\"awaiting_approval\"}" + + "{\"toolCallId\":\"call_c\",\"name\":\"c\",\"status\":\"awaiting_approval\"}," + + "{\"toolCallId\":\"call_d\",\"name\":\"d\",\"status\":\"interrupted\"}" + "]}"); List calls = BaseAgent.extractCompletedToolCalls(msg); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ReasoningDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ReasoningDispatcherTest.java index 2fd73f11..04738e20 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ReasoningDispatcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ReasoningDispatcherTest.java @@ -54,6 +54,18 @@ class ReasoningDispatcherTest { assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state)); } + @Test + @DisplayName("动作完成门控请求续跑时回到 reasoning") + void shouldContinueReasoningWhenCompletionGateRejectsCandidate() throws Exception { + OverAllState state = new OverAllState(Map.of( + CONTINUE_REASONING, true, + NEEDS_TOOL_CALL, false, + CURRENT_ITERATION, 0, + MAX_ITERATIONS, 10 + )); + assertEquals(REASONING_NODE, dispatcher.apply(state)); + } + @Test @DisplayName("迭代超限时路由到 limit_exceeded") void shouldRouteToLimitExceededWhenOverLimit() throws Exception { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ActionCompletionPolicyTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ActionCompletionPolicyTest.java new file mode 100644 index 00000000..ef9d1ede --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ActionCompletionPolicyTest.java @@ -0,0 +1,56 @@ +package vip.mate.agent.graph.guard; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.graph.state.ActionExecutionLedger; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ActionCompletionPolicyTest { + + @Test + void ordinaryQuestionAllowsTextAnswer() { + assertEquals(ActionCompletionPolicy.Decision.ALLOW, + ActionCompletionPolicy.evaluate(false, 0, ActionExecutionLedger.empty())); + } + + @Test + void successfulActionAllowsTextAnswer() { + ActionExecutionLedger ledger = ledger("schedule_meeting", true); + assertEquals(ActionCompletionPolicy.Decision.ALLOW, + ActionCompletionPolicy.evaluate(true, 0, ledger)); + } + + @Test + void missingAttemptGetsOneContinuation() { + assertEquals(ActionCompletionPolicy.Decision.RETRY, + ActionCompletionPolicy.evaluate(true, 0, ActionExecutionLedger.empty())); + } + + @Test + void missingAttemptAfterContinuationIsUnverified() { + assertEquals(ActionCompletionPolicy.Decision.UNVERIFIED, + ActionCompletionPolicy.evaluate(true, 1, ActionExecutionLedger.empty())); + } + + @Test + void failedActionTerminatesAsFailure() { + ActionExecutionLedger ledger = ledger("schedule_meeting", false); + assertEquals(ActionCompletionPolicy.Decision.FAILED, + ActionCompletionPolicy.evaluate(true, 0, ledger)); + } + + @Test + void successfulDisclosureCallStillRequiresAction() { + ActionExecutionLedger ledger = ledger("load_skill", true); + assertEquals(ActionCompletionPolicy.Decision.RETRY, + ActionCompletionPolicy.evaluate(true, 0, ledger)); + } + + private static ActionExecutionLedger ledger(String toolName, boolean success) { + return ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("id-1", toolName, "result", success))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeAutoRecordSkipTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeAutoRecordSkipTest.java index f9d6fb5b..7709e4ca 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeAutoRecordSkipTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeAutoRecordSkipTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.ToolResponseMessage; import vip.mate.agent.progress.ProgressLedger; import vip.mate.agent.progress.ProgressLedgerService; +import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.graph.state.ActionExecutionLedger; import java.util.List; import java.util.Map; @@ -114,4 +116,18 @@ class ActionNodeAutoRecordSkipTest { assertTrue(ledger.load(conv).isEmpty()); } + + @Test + @DisplayName("failed mutating tools are not auto-recorded as completed progress") + void failedMutationIsNotRecorded() { + InMemoryProgressLedgerService ledger = new InMemoryProgressLedgerService(); + ActionNode node = nodeWith(ledger); + ToolResponseMessage.ToolResponse response = resp("schedule_meeting", "HTTP 500"); + ActionExecutionLedger receipts = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete(response.id(), response.name(), response.responseData(), false))); + + node.autoRecordToolCalls("conv-failed", List.of(response), receipts); + + assertTrue(ledger.load("conv-failed").isEmpty()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java index 4f5560be..880ae959 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ActionNodeLoadSkillTest.java @@ -3,8 +3,11 @@ package vip.mate.agent.graph.node; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.runtime.model.ResolvedSkill; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -92,4 +95,32 @@ class ActionNodeLoadSkillTest { assertTrue(ActionNode.extractEnabledToolNames( List.of(call("load_skill", "{\"skillName\":\"pdf\"}"))).isEmpty()); } + + @Test + @DisplayName("executable skill manifests require action completion") + void executableManifestRequiresActionCompletion() { + assertTrue(ActionNode.manifestRequiresActionCompletion( + SkillManifest.builder().type("mcp").build())); + assertTrue(ActionNode.manifestRequiresActionCompletion( + SkillManifest.builder().allowedTools(List.of("schedule_meeting")).build())); + } + + @Test + @DisplayName("prompt-only skill manifests do not require an action") + void promptManifestDoesNotRequireActionCompletion() { + assertTrue(!ActionNode.manifestRequiresActionCompletion( + SkillManifest.builder().type("prompt").build())); + assertTrue(!ActionNode.manifestRequiresActionCompletion(null)); + } + + @Test + @DisplayName("legacy executable skill is detected from its resolved script tree") + void legacySkillWithScriptsRequiresActionCompletion() { + ResolvedSkill skill = ResolvedSkill.builder() + .name("tencent-meeting-mcp") + .scripts(Map.of("tencent_meeting.py", Map.of("type", "file"))) + .build(); + + assertTrue(ActionNode.resolvedSkillRequiresActionCompletion(skill)); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java index 0ef58218..acf25a25 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java @@ -11,7 +11,9 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.tool.ToolCallback; import vip.mate.agent.AgentToolSet; +import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.state.ActionExecutionLedger; import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.channel.web.ChatStreamTracker; @@ -103,6 +105,60 @@ class ReasoningNodeOutputTest { assertEquals("回答内容", output.get(FINAL_ANSWER)); } + @Test + @DisplayName("action-required text-only candidate requests one reasoning continuation") + void actionRequiredTextOnly_continuesOnce() throws Exception { + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "预约成功", "", new AssistantMessage("预约成功"), + List.of(), false, 100, 50); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(ACTION_COMPLETION_REQUIRED, true); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(true, output.get(CONTINUE_REASONING)); + assertEquals(1, output.get(ACTION_COMPLETION_RETRY_COUNT)); + assertEquals("", output.get(FINAL_ANSWER)); + assertEquals(2, ((List) output.get(MESSAGES)).size()); + } + + @Test + @DisplayName("action-required text-only candidate becomes unverified after bounded continuation") + void actionRequiredTextOnly_retryExhausted() throws Exception { + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "预约成功", "", new AssistantMessage("预约成功"), + List.of(), false, 100, 50); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(ACTION_COMPLETION_REQUIRED, true); + state.put(ACTION_COMPLETION_RETRY_COUNT, 1); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(CONTINUE_REASONING)); + assertEquals("action_unverified", output.get(FINISH_REASON)); + assertTrue(((String) output.get(FINAL_ANSWER)).contains("未观察到实际")); + } + + @Test + @DisplayName("failed action receipt overrides a model success claim") + void failedActionReceipt_blocksSuccessClaim() throws Exception { + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "预约成功", "", new AssistantMessage("预约成功"), + List.of(), false, 100, 50); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(ACTION_COMPLETION_REQUIRED, true); + state.put(ACTION_EXECUTION_LEDGER, ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("call-1", "schedule_meeting", "HTTP 500", false)))); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals("action_failed", output.get(FINISH_REASON)); + assertTrue(((String) output.get(FINAL_ANSWER)).contains("执行失败")); + } + @Test @DisplayName("源码证据不足的 final answer:原文进 streamedContent,警告作为 finalAnswer 追加") void evidenceInsufficientFinalAnswer_splitsPersistedContentAndWarning() throws Exception { @@ -236,4 +292,17 @@ class ReasoningNodeOutputTest { assertEquals("stopped", output.get(FINISH_REASON)); assertEquals("部分内容", output.get(FINAL_ANSWER)); } + + private Map baseStateMap() { + Map state = new HashMap<>(); + state.put(CONVERSATION_ID, "test-conv"); + state.put(SYSTEM_PROMPT, "you are a helper"); + state.put(USER_MESSAGE, "book the meeting"); + state.put(MESSAGES, List.of()); + state.put(CURRENT_ITERATION, 0); + state.put(MAX_ITERATIONS, 10); + state.put(LLM_CALL_COUNT, 0); + state.put(FORCED_TOOL_CALL, ""); + return state; + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java new file mode 100644 index 00000000..ed2df29d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java @@ -0,0 +1,59 @@ +package vip.mate.agent.graph.state; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.GraphEventPublisher; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ActionExecutionLedgerTest { + + @Test + void successfulBusinessToolSatisfiesActionCompletion() { + ActionExecutionLedger ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("call-1", "schedule_meeting", "created", true))); + + assertTrue(ledger.hasSubstantiveAttempt()); + assertTrue(ledger.hasSuccessfulSubstantiveCall()); + assertEquals(ActionExecutionLedger.Status.SUCCEEDED, + ledger.receipts().get("call-1").status()); + } + + @Test + void failedBusinessToolIsAnAttemptButNotSuccessfulEvidence() { + ActionExecutionLedger ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("call-2", "schedule_meeting", "HTTP 500", false))); + + assertTrue(ledger.hasSubstantiveAttempt()); + assertFalse(ledger.hasSuccessfulSubstantiveCall()); + assertEquals(ActionExecutionLedger.Status.FAILED, + ledger.receipts().get("call-2").status()); + } + + @Test + void disclosureToolsNeverSatisfyActionCompletion() { + ActionExecutionLedger ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("load-1", "load_skill", "skill body", true), + GraphEventPublisher.toolComplete("enable-1", "enable_tool", "enabled", true))); + + assertFalse(ledger.hasSubstantiveAttempt()); + assertFalse(ledger.hasSuccessfulSubstantiveCall()); + assertEquals(2, ledger.receipts().size()); + } + + @Test + void mergePreservesReceiptsAcrossIterations() { + ActionExecutionLedger first = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("load-1", "load_skill", "skill body", true))); + ActionExecutionLedger second = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("call-3", "schedule_meeting", "created", true))); + + ActionExecutionLedger merged = first.merge(second); + + assertEquals(2, merged.receipts().size()); + assertTrue(merged.hasSuccessfulSubstantiveCall()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorKindTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorKindTest.java index 7de69c55..08db2a16 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorKindTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorKindTest.java @@ -99,6 +99,26 @@ class AgentStreamAccumulatorKindTest { assertEquals("grounded_narration", segments.get(0).path("kind").asText()); } + @Test + @DisplayName("started-only tool calls persist as interrupted, never completed") + void startedOnlyToolCallRemainsInterrupted() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK); + + acc.accept(StreamDelta.event("tool_call_started", + Map.of("toolCallId", "orphan-1", "toolName", "schedule_meeting", + "arguments", "{\"title\":\"review\"}")), "conv-interrupted"); + + JsonNode metadata = mapper.readTree(acc.toMetadataJson()); + JsonNode call = metadata.path("toolCalls").get(0); + JsonNode segment = metadata.path("segments").get(0); + + assertEquals("interrupted", call.path("status").asText()); + assertFalse(call.has("result"), "no completion event means there is no tool result"); + assertEquals("interrupted", segment.path("status").asText()); + assertFalse(segment.has("toolResult"), "timeline must not manufacture a result"); + } + @Test @DisplayName("Plan step result stays in plan metadata while final summary exclusively owns message content") void planStepResultDoesNotDuplicateFinalSummary() throws Exception {