mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(agent): enforce tool-backed action completion
This commit is contained in:
parent
a11c7ccb52
commit
071fff76ab
@ -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))
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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 非空)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 =====
|
||||
|
||||
/**
|
||||
|
||||
@ -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 保留兼容旧 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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user