fix(agent): enforce tool-backed action completion

This commit is contained in:
matevip 2026-08-11 05:31:44 -04:00
parent a11c7ccb52
commit 071fff76ab
19 changed files with 565 additions and 14 deletions

View File

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

View File

@ -309,9 +309,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
String streamed = output.state().<String>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().<String>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));
}

View File

@ -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 非空)

View File

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

View File

@ -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<String> 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<ToolResponseMessage.ToolResponse> responses) {
autoRecordToolCalls(conversationId, responses, null);
}
void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> 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<vip.mate.agent.progress.ProgressLedgerService.AutoRecordEntry> 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;

View File

@ -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(),

View File

@ -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<String> 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<String, Receipt> receipts;
private ActionExecutionLedger(Map<String, Receipt> receipts) {
this.receipts = Map.copyOf(receipts);
}
public static ActionExecutionLedger empty() {
return EMPTY;
}
public static ActionExecutionLedger fromEvents(List<GraphEventPublisher.GraphEvent> events) {
if (events == null || events.isEmpty()) return empty();
Map<String, Receipt> receipts = new LinkedHashMap<>();
int legacyIndex = 0;
for (GraphEventPublisher.GraphEvent event : events) {
if (event == null || !GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())) continue;
Map<String, Object> 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<String, Receipt> 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<String, Receipt> merged = new LinkedHashMap<>(receipts);
merged.putAll(other.receipts);
return new ActionExecutionLedger(merged);
}
}

View File

@ -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"),

View File

@ -216,6 +216,22 @@ public final class MateClawStateAccessor {
return state.<SourceEvidenceLedger>value(SOURCE_EVIDENCE_LEDGER).orElse(SourceEvidenceLedger.empty());
}
public ActionExecutionLedger actionExecutionLedger() {
return state.<ActionExecutionLedger>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);

View File

@ -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 =====
/**

View File

@ -476,9 +476,16 @@ public final class AgentStreamAccumulator {
return parts;
}
private void finalizeToolCalls() {
private void interruptUnfinishedToolCalls() {
for (Map<String, Object> tc : toolCalls) {
if ("running".equals(tc.get("status"))) tc.put("status", "completed");
if ("running".equals(tc.get("status"))) tc.put("status", "interrupted");
}
for (Map<String, Object> 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 保留兼容旧 UIsegments 是按事件顺序的完整时间线
*/
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.

View File

@ -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<BaseAgent.PersistedToolCall> calls = BaseAgent.extractCompletedToolCalls(msg);

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, Object> state = baseStateMap();
state.put(ACTION_COMPLETION_REQUIRED, true);
Map<String, Object> 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<String, Object> state = baseStateMap();
state.put(ACTION_COMPLETION_REQUIRED, true);
state.put(ACTION_COMPLETION_RETRY_COUNT, 1);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> baseStateMap() {
Map<String, Object> 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;
}
}

View File

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

View File

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