mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(goal): graph topology + evaluation node wired into ReAct + Plan-Execute
This commit is contained in:
parent
ac6c8a18e3
commit
ce74a0ae48
@ -21,6 +21,7 @@ import vip.mate.agent.graph.edge.ObservationDispatcher;
|
||||
import vip.mate.agent.graph.edge.ReasoningDispatcher;
|
||||
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
|
||||
import vip.mate.agent.graph.node.*;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.agent.graph.observation.ObservationProcessor;
|
||||
import vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent;
|
||||
import vip.mate.agent.graph.plan.edge.PlanGenerationDispatcher;
|
||||
@ -111,6 +112,10 @@ public class AgentGraphBuilder {
|
||||
private final vip.mate.llm.chatmodel.DashScopeChatModelBuilder dashScopeBuilder;
|
||||
private final vip.mate.llm.routing.MultimodalRouter multimodalRouter;
|
||||
private final vip.mate.llm.routing.MediaCaptionService mediaCaptionService;
|
||||
private final vip.mate.goal.service.GoalService goalService;
|
||||
private final vip.mate.goal.service.GoalEvaluationService goalEvaluationService;
|
||||
private final vip.mate.goal.service.GoalFollowupService goalFollowupService;
|
||||
private final vip.mate.goal.config.GoalProperties goalProperties;
|
||||
|
||||
/**
|
||||
* Optional audit pipeline. Setter injection (rather than a constructor
|
||||
@ -305,6 +310,11 @@ public class AgentGraphBuilder {
|
||||
agent.runtimeProviderId = provider != null ? provider.getProviderId() : "";
|
||||
agent.runtimeModelConfig = runtimeModel;
|
||||
agent.toolSet = toolSet;
|
||||
// RFC 48 — wire the goal lookup so buildInitialState can inject
|
||||
// ACTIVE_GOAL. The node itself stays inert until goalProperties.enabled
|
||||
// flips true, but tests need findActiveByConversation to work even
|
||||
// when the runtime path is disabled.
|
||||
agent.goalService = goalService;
|
||||
agent.multimodalRouter = multimodalRouter;
|
||||
agent.mediaCaptionService = mediaCaptionService;
|
||||
agent.userLocale = resolveLocale();
|
||||
@ -467,6 +477,16 @@ public class AgentGraphBuilder {
|
||||
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, 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
|
||||
// BOTH graph KeyStrategyFactory blocks. The architecture
|
||||
// coverage test only checks "appears somewhere"; the
|
||||
// GoalStateKeyDoubleRegistrationTest below verifies the
|
||||
// double registration explicitly.
|
||||
.addStrategy(MateClawStateKeys.ACTIVE_GOAL, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATION_RESULT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
||||
.build();
|
||||
|
||||
// Graph 拓扑:
|
||||
@ -474,7 +494,16 @@ public class AgentGraphBuilder {
|
||||
// ├→ DIRECT_ANSWER_NODE → END
|
||||
// └→ STEP_EXECUTION → (StepProgressDispatcher)
|
||||
// ├→ STEP_EXECUTION (loop)
|
||||
// └→ PLAN_SUMMARY → END
|
||||
// └→ PLAN_SUMMARY → (active goal?)
|
||||
// ├→ GOAL_EVALUATION → (followup?)
|
||||
// │ ├→ PLAN_GENERATION (re-plan)
|
||||
// │ └→ END
|
||||
// └→ END
|
||||
|
||||
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
|
||||
goalEvaluationService, goalFollowupService, goalService, goalProperties,
|
||||
conversationWindowManager, conversationService,
|
||||
vip.mate.goal.service.GraphFlavor.PLAN_EXECUTE);
|
||||
|
||||
StateGraph graph = new StateGraph("plan-execute-agent", keyStrategyFactory)
|
||||
.addNode(PlanStateKeys.PLAN_GENERATION_NODE,
|
||||
@ -485,6 +514,8 @@ public class AgentGraphBuilder {
|
||||
AsyncNodeAction.node_async(planSummaryNode))
|
||||
.addNode(PlanStateKeys.DIRECT_ANSWER_NODE,
|
||||
AsyncNodeAction.node_async(directAnswerNode))
|
||||
.addNode(MateClawStateKeys.GOAL_EVALUATION_NODE,
|
||||
AsyncNodeAction.node_async(goalEvalNode))
|
||||
.addEdge(StateGraph.START, PlanStateKeys.PLAN_GENERATION_NODE)
|
||||
.addConditionalEdges(PlanStateKeys.PLAN_GENERATION_NODE,
|
||||
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
|
||||
@ -497,7 +528,24 @@ public class AgentGraphBuilder {
|
||||
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
||||
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
|
||||
StateGraph.END, StateGraph.END))
|
||||
.addEdge(PlanStateKeys.PLAN_SUMMARY_NODE, StateGraph.END)
|
||||
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
|
||||
AsyncEdgeAction.edge_async(state -> {
|
||||
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
||||
boolean hasGoal = a.hasActiveGoal();
|
||||
boolean already = a.goalEvaluatedThisRun();
|
||||
return (hasGoal && !already)
|
||||
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
||||
: StateGraph.END;
|
||||
}),
|
||||
Map.of(
|
||||
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
|
||||
StateGraph.END, StateGraph.END))
|
||||
.addConditionalEdges(MateClawStateKeys.GOAL_EVALUATION_NODE,
|
||||
AsyncEdgeAction.edge_async(new vip.mate.agent.graph.edge.GoalEvaluationDispatcher(
|
||||
PlanStateKeys.PLAN_GENERATION_NODE, StateGraph.END)),
|
||||
Map.of(
|
||||
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
|
||||
StateGraph.END, StateGraph.END))
|
||||
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
|
||||
|
||||
return graph.compile(CompileConfig.builder()
|
||||
@ -655,8 +703,22 @@ public class AgentGraphBuilder {
|
||||
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, 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
|
||||
// BOTH graph KeyStrategyFactory blocks. See
|
||||
// GoalStateKeyDoubleRegistrationTest for the strict
|
||||
// double-registration check.
|
||||
.addStrategy(MateClawStateKeys.ACTIVE_GOAL, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATION_RESULT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
|
||||
.build();
|
||||
|
||||
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
|
||||
goalEvaluationService, goalFollowupService, goalService, goalProperties,
|
||||
conversationWindowManager, conversationService,
|
||||
vip.mate.goal.service.GraphFlavor.REACT);
|
||||
|
||||
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
|
||||
.addNode(MateClawStateKeys.REASONING_NODE,
|
||||
AsyncNodeAction.node_async(reasoningNode))
|
||||
@ -670,6 +732,8 @@ public class AgentGraphBuilder {
|
||||
AsyncNodeAction.node_async(limitExceededNode))
|
||||
.addNode(MateClawStateKeys.FINAL_ANSWER_NODE,
|
||||
AsyncNodeAction.node_async(finalAnswerNode))
|
||||
.addNode(MateClawStateKeys.GOAL_EVALUATION_NODE,
|
||||
AsyncNodeAction.node_async(goalEvalNode))
|
||||
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
|
||||
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
|
||||
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
|
||||
@ -686,7 +750,26 @@ public class AgentGraphBuilder {
|
||||
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE))
|
||||
.addEdge(MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.REASONING_NODE)
|
||||
.addEdge(MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.FINAL_ANSWER_NODE)
|
||||
.addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END);
|
||||
// FinalAnswer -> (active goal && not yet evaluated this run) ? GoalEvaluation : END
|
||||
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
|
||||
AsyncEdgeAction.edge_async(state -> {
|
||||
MateClawStateAccessor a = new MateClawStateAccessor(state);
|
||||
boolean hasGoal = a.hasActiveGoal();
|
||||
boolean already = a.goalEvaluatedThisRun();
|
||||
return (hasGoal && !already)
|
||||
? MateClawStateKeys.GOAL_EVALUATION_NODE
|
||||
: StateGraph.END;
|
||||
}),
|
||||
Map.of(
|
||||
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
|
||||
StateGraph.END, StateGraph.END))
|
||||
// GoalEvaluation -> (followup injected) ? Reasoning : END
|
||||
.addConditionalEdges(MateClawStateKeys.GOAL_EVALUATION_NODE,
|
||||
AsyncEdgeAction.edge_async(new vip.mate.agent.graph.edge.GoalEvaluationDispatcher(
|
||||
MateClawStateKeys.REASONING_NODE, StateGraph.END)),
|
||||
Map.of(
|
||||
MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
|
||||
StateGraph.END, StateGraph.END));
|
||||
|
||||
return graph.compile(CompileConfig.builder()
|
||||
.recursionLimit(frameworkRecursionLimit())
|
||||
|
||||
@ -118,6 +118,14 @@ public abstract class BaseAgent {
|
||||
protected MultimodalRouter multimodalRouter;
|
||||
protected MediaCaptionService mediaCaptionService;
|
||||
|
||||
/**
|
||||
* RFC 48 — wired by {@link AgentGraphBuilder#build} so the agent's
|
||||
* {@code buildInitialState} can inject {@code ACTIVE_GOAL} from the
|
||||
* conversation's active goal row. Nullable when the goal subsystem
|
||||
* is off / not wired (legacy tests with minimal builders).
|
||||
*/
|
||||
protected vip.mate.goal.service.GoalService goalService;
|
||||
|
||||
/** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */
|
||||
protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE;
|
||||
|
||||
|
||||
@ -547,6 +547,29 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(CHAT_ORIGIN, origin);
|
||||
|
||||
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
||||
// The node + dispatcher both bail out when ACTIVE_GOAL is absent,
|
||||
// so this is a no-op for conversations without a bound goal.
|
||||
// GOAL_EVALUATED_THIS_RUN explicitly seeded so the FinalAnswer→
|
||||
// GoalEvaluation conditional edge sees a clean false on each new
|
||||
// chat invocation (RFC 48 §6.3 exhaustsBudgetAndStopsLooping
|
||||
// depends on this — every new chat is a fresh evaluation pass).
|
||||
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
|
||||
try {
|
||||
vip.mate.goal.model.GoalEntity active =
|
||||
goalService.findActiveByConversation(conversationId);
|
||||
if (active != null) {
|
||||
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
||||
}
|
||||
}
|
||||
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
|
||||
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
|
||||
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package vip.mate.agent.graph.edge;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
|
||||
|
||||
/**
|
||||
* Decides whether to re-enter the reasoning loop with an injected
|
||||
* follow-up prompt or terminate the graph run.
|
||||
*
|
||||
* <p>Both targets are passed in by the builder so the same class serves
|
||||
* the ReAct graph (followup -> {@code REASONING_NODE}, terminal ->
|
||||
* {@code END}) and the Plan-Execute graph (followup ->
|
||||
* {@code PLAN_GENERATION_NODE}, terminal -> {@code END}) without
|
||||
* branching on graph type at runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GoalEvaluationDispatcher implements EdgeAction {
|
||||
|
||||
/** Where to re-enter the loop when GoalEvaluationNode injected a followup. */
|
||||
private final String followupTarget;
|
||||
|
||||
/** Where to go on the normal terminal path (typically {@code END}). */
|
||||
private final String terminalTarget;
|
||||
|
||||
@Override
|
||||
public String apply(OverAllState state) {
|
||||
boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false));
|
||||
if (followup) {
|
||||
log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
|
||||
return followupTarget;
|
||||
}
|
||||
return terminalTarget;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
package vip.mate.agent.graph.node;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.ConversationWindowManager;
|
||||
import vip.mate.agent.graph.state.FinishReason;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.goal.service.GoalEvaluationService;
|
||||
import vip.mate.goal.service.GoalFollowupService;
|
||||
import vip.mate.goal.service.GoalService;
|
||||
import vip.mate.goal.service.GraphFlavor;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Sits between FinalAnswerNode (or PlanSummaryNode) and the graph END.
|
||||
*
|
||||
* <p>Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so
|
||||
* upstream finishReason / evidence checks are already authoritative. The
|
||||
* node:
|
||||
* <ol>
|
||||
* <li>Bails out for the "this turn shouldn't count" finishReasons
|
||||
* (evidence_insufficient, stopped, error_fallback, return_direct,
|
||||
* max_iterations_reached, plus awaiting_approval).</li>
|
||||
* <li>Otherwise calls the evaluator, persists the
|
||||
* agent/eval LLM-call deltas + score + gap via GoalService.</li>
|
||||
* <li>Decides completed / exhausted / followup / continue. Completed
|
||||
* and exhausted update {@code mate_agent_goal.status} ONLY — they
|
||||
* never touch FINISH_REASON, since the graph's own terminal status
|
||||
* is independent of goal status.</li>
|
||||
* <li>On followup, sets GOAL_FOLLOWUP_PROMPT and clears whichever
|
||||
* graph-specific state would otherwise short-circuit the re-entry
|
||||
* pass (clear set depends on the constructor-time GraphFlavor).</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
public class GoalEvaluationNode implements NodeAction {
|
||||
|
||||
private final GoalEvaluationService evaluationService;
|
||||
private final GoalFollowupService followupService;
|
||||
private final GoalService goalService;
|
||||
private final GoalProperties properties;
|
||||
private final ConversationWindowManager windowManager; // unused PR2, kept for PR5
|
||||
private final ConversationService conversationService; // unused PR2, kept for PR5
|
||||
private final GraphFlavor flavor;
|
||||
|
||||
public GoalEvaluationNode(GoalEvaluationService evaluationService,
|
||||
GoalFollowupService followupService,
|
||||
GoalService goalService,
|
||||
GoalProperties properties,
|
||||
ConversationWindowManager windowManager,
|
||||
ConversationService conversationService,
|
||||
GraphFlavor flavor) {
|
||||
this.evaluationService = evaluationService;
|
||||
this.followupService = followupService;
|
||||
this.goalService = goalService;
|
||||
this.properties = properties;
|
||||
this.windowManager = windowManager;
|
||||
this.conversationService = conversationService;
|
||||
this.flavor = flavor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||
// Master kill switch — node stays inert until PR5 flips this.
|
||||
if (!properties.isEnabled()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
|
||||
|
||||
Optional<Object> goalOpt = accessor.activeGoal();
|
||||
if (goalOpt.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// Re-entry guard — the FinalAnswer→GoalEvaluation conditional edge
|
||||
// also checks this, but defence in depth pays for itself here.
|
||||
if (accessor.goalEvaluatedThisRun()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
|
||||
// determines whether this turn counts. Plan-Execute usually doesn't
|
||||
// set finishReason on the happy path, so we only enforce these
|
||||
// exit conditions in REACT mode + the universal awaiting_approval
|
||||
// gate that both flavors share.
|
||||
if (flavor == GraphFlavor.REACT) {
|
||||
String fr = accessor.finishReason();
|
||||
if (FinishReason.EVIDENCE_INSUFFICIENT.getValue().equals(fr)
|
||||
|| FinishReason.STOPPED.getValue().equals(fr)
|
||||
|| FinishReason.ERROR_FALLBACK.getValue().equals(fr)
|
||||
|| FinishReason.RETURN_DIRECT.getValue().equals(fr)
|
||||
|| FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(fr)) {
|
||||
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluatedThisRun(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
if (accessor.awaitingApproval()) {
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluatedThisRun(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
Object goalObj = goalOpt.get();
|
||||
if (!(goalObj instanceof GoalEntity goal)) {
|
||||
log.warn("[GoalEvaluationNode] ACTIVE_GOAL is not a GoalEntity: {}", goalObj.getClass());
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluatedThisRun(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
String terminal = accessor.terminalAnswer();
|
||||
if (terminal.isEmpty()) {
|
||||
log.warn("[GoalEvaluationNode] terminalAnswer empty (flavor={}); skipping evaluation", flavor);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluatedThisRun(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
// Build a thin recent-messages slice for the evaluator prompt.
|
||||
List<Message> recent = accessor.messages();
|
||||
int max = properties.getEvaluatorContextMessages();
|
||||
if (recent.size() > max) {
|
||||
recent = recent.subList(recent.size() - max, recent.size());
|
||||
}
|
||||
|
||||
GoalEvaluationResult result = evaluationService.evaluate(goal, recent, terminal);
|
||||
|
||||
// Pre-eval agent_llm count snapshot — the bookkeeping helper folds
|
||||
// it into the per-goal counter so future turns see growing usage.
|
||||
int agentLlmDelta = accessor.llmCallCount();
|
||||
int evalLlmDelta = result.llmCallsConsumed();
|
||||
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
|
||||
|
||||
GoalEntity refreshed = goalService.getById(goal.getId());
|
||||
|
||||
// Decision branches.
|
||||
if (result.completed() || result.score() >= 0.95) {
|
||||
goalService.markCompleted(refreshed.getId(), result);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score()))))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (goalService.isBudgetExhausted(refreshed)) {
|
||||
String reason = goalService.exhaustionReason(refreshed);
|
||||
goalService.markExhausted(refreshed.getId(), reason);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"turnsUsed", refreshed.getTurnsUsed(),
|
||||
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
|
||||
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
|
||||
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
|
||||
"reason", reason))))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<String> followup = followupService.maybeBuildFollowup(refreshed, result);
|
||||
if (followup.isPresent()) {
|
||||
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
||||
MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalFollowupInjected(true)
|
||||
.goalFollowupPrompt(followup.get())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.needsToolCall(false)
|
||||
.events(List.of(goalEvent("goal_followup", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"prompt", followup.get()))));
|
||||
|
||||
if (flavor == GraphFlavor.REACT) {
|
||||
// ReAct: append the followup as a fresh user message via the
|
||||
// MESSAGES APPEND strategy. ReasoningNode picks it up on its
|
||||
// next call without any followup-specific logic on its side.
|
||||
out.clearFinalAnswer()
|
||||
.clearFinishReason()
|
||||
.messages(List.of((Message) new UserMessage(followup.get())));
|
||||
} else {
|
||||
// Plan-Execute: wipe the wider mid-pass + terminal state.
|
||||
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
|
||||
// preserved — the next PlanGeneration pass needs them.
|
||||
out.clearFinalAnswer()
|
||||
.clearFinishReason()
|
||||
.clearPlanFinalSummary()
|
||||
.clearPlanDirectAnswer()
|
||||
.clearPlanId()
|
||||
.clearPlanSteps()
|
||||
.clearPlanValid()
|
||||
.clearNeedsPlanning()
|
||||
.clearCurrentStepIndex()
|
||||
.clearCurrentStepTitle()
|
||||
.clearCurrentStepResult()
|
||||
.clearCompletedResults()
|
||||
.clearFinalSummaryThinking()
|
||||
.clearCurrentStepThinking();
|
||||
}
|
||||
return out.build();
|
||||
}
|
||||
|
||||
// Continue but no follow-up — just record the evaluation event.
|
||||
// (helper below avoids needing a custom() factory on GraphEventPublisher.)
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_evaluated", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score(),
|
||||
"gap", result.gap() == null ? "" : result.gap()))))
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */
|
||||
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
|
||||
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
@ -319,6 +319,24 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
origin = origin.withConversationId(conversationId)
|
||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||
|
||||
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
||||
// Mirrors StateGraphReActAgent.buildInitialState exactly.
|
||||
if (goalService != null && conversationId != null && !conversationId.isBlank()) {
|
||||
try {
|
||||
vip.mate.goal.model.GoalEntity active =
|
||||
goalService.findActiveByConversation(conversationId);
|
||||
if (active != null) {
|
||||
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
||||
}
|
||||
}
|
||||
inputs.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
|
||||
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, false);
|
||||
inputs.put(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
|
||||
@ -115,6 +115,20 @@ public class PlanGenerationNode implements NodeAction {
|
||||
public Map<String, Object> apply(OverAllState state) throws Exception {
|
||||
PlanStateAccessor accessor = new PlanStateAccessor(state);
|
||||
String goal = accessor.goal();
|
||||
|
||||
// Goal follow-up injection: GoalEvaluationNode requested a re-plan
|
||||
// pass with extra guidance. The mid-pass plan state was wiped by
|
||||
// the previous node, so we run the normal planning flow but
|
||||
// append the follow-up prompt to the user goal so the planner
|
||||
// sees "do these original objectives + this next step the
|
||||
// evaluator just asked for".
|
||||
String followupPrompt = state.value(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, "");
|
||||
if (!followupPrompt.isEmpty()) {
|
||||
log.info("[PlanGeneration] Goal follow-up active, augmenting goal with {} chars of guidance",
|
||||
followupPrompt.length());
|
||||
goal = goal + "\n\n[Follow-up guidance]\n" + followupPrompt;
|
||||
}
|
||||
|
||||
String systemPrompt = accessor.systemPrompt();
|
||||
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
|
||||
String conversationId = accessor.conversationId();
|
||||
|
||||
@ -246,6 +246,57 @@ public final class MateClawStateAccessor {
|
||||
return state.value(RUNTIME_PROVIDER_ID, "");
|
||||
}
|
||||
|
||||
// ===== Persistent goal accessors =====
|
||||
|
||||
/**
|
||||
* Active goal snapshot or empty. The injected object is the
|
||||
* {@code vip.mate.goal.model.GoalEntity}; we reference it by Object
|
||||
* here to avoid pulling the goal package into core graph state.
|
||||
*/
|
||||
public Optional<Object> activeGoal() {
|
||||
return state.<Object>value(ACTIVE_GOAL);
|
||||
}
|
||||
|
||||
public boolean hasActiveGoal() {
|
||||
return state.<Object>value(ACTIVE_GOAL).isPresent();
|
||||
}
|
||||
|
||||
public boolean goalEvaluatedThisRun() {
|
||||
return state.value(GOAL_EVALUATED_THIS_RUN, false);
|
||||
}
|
||||
|
||||
public boolean goalFollowupInjected() {
|
||||
return state.value(GOAL_FOLLOWUP_INJECTED, false);
|
||||
}
|
||||
|
||||
public String goalFollowupPrompt() {
|
||||
return state.value(GOAL_FOLLOWUP_PROMPT, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
|
||||
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
|
||||
* Plan-Execute writes to {@code PlanStateKeys.FINAL_SUMMARY} (long
|
||||
* path) or {@code PlanStateKeys.DIRECT_ANSWER} (short path). The
|
||||
* GoalEvaluationNode reads whichever is populated without having to
|
||||
* know which graph it's inside.
|
||||
*/
|
||||
public String terminalAnswer() {
|
||||
String fa = state.value(FINAL_ANSWER, "");
|
||||
if (!fa.isEmpty()) {
|
||||
return fa;
|
||||
}
|
||||
// Avoid a direct compile-time reference to PlanStateKeys (the plan
|
||||
// sub-package depends on core graph state); use the string keys
|
||||
// verbatim. Mismatches would surface as terminalAnswer() returning
|
||||
// empty in tests — the v3 TerminalAnswerTest pins exactly that.
|
||||
String summary = state.value("final_summary", "");
|
||||
if (!summary.isEmpty()) {
|
||||
return summary;
|
||||
}
|
||||
return state.value("direct_answer", "");
|
||||
}
|
||||
|
||||
// ===== 输出构建器 =====
|
||||
|
||||
/**
|
||||
@ -435,6 +486,89 @@ public final class MateClawStateAccessor {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ---- Persistent goal ----
|
||||
|
||||
public OutputBuilder goalEvaluationResult(Map<String, Object> result) {
|
||||
return put(GOAL_EVALUATION_RESULT, result);
|
||||
}
|
||||
|
||||
public OutputBuilder goalFollowupInjected(boolean injected) {
|
||||
return put(GOAL_FOLLOWUP_INJECTED, injected);
|
||||
}
|
||||
|
||||
public OutputBuilder goalFollowupPrompt(String prompt) {
|
||||
return put(GOAL_FOLLOWUP_PROMPT, prompt);
|
||||
}
|
||||
|
||||
public OutputBuilder goalEvaluatedThisRun(boolean v) {
|
||||
return put(GOAL_EVALUATED_THIS_RUN, v);
|
||||
}
|
||||
|
||||
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
|
||||
* immediately re-terminate via the existing final text. */
|
||||
public OutputBuilder clearFinalAnswer() {
|
||||
return put(FINAL_ANSWER, "");
|
||||
}
|
||||
|
||||
/** Wipe FINISH_REASON for the same reason as clearFinalAnswer(). */
|
||||
public OutputBuilder clearFinishReason() {
|
||||
return put(FINISH_REASON, "");
|
||||
}
|
||||
|
||||
/** Plan-Execute follow-up: clear the terminal-side plan summary so
|
||||
* the next PlanGeneration pass starts clean. Identifier is the
|
||||
* string literal "final_summary" to avoid a compile-time link to
|
||||
* the plan sub-package from core graph state. */
|
||||
public OutputBuilder clearPlanFinalSummary() {
|
||||
return put("final_summary", "");
|
||||
}
|
||||
|
||||
public OutputBuilder clearPlanDirectAnswer() {
|
||||
return put("direct_answer", "");
|
||||
}
|
||||
|
||||
/** Plan-Execute follow-up: wipe the mid-pass plan state so the next
|
||||
* PlanGenerationNode pass re-derives everything from scratch. */
|
||||
public OutputBuilder clearPlanId() {
|
||||
return put("plan_id", null);
|
||||
}
|
||||
|
||||
public OutputBuilder clearPlanSteps() {
|
||||
return put("plan_steps", List.of());
|
||||
}
|
||||
|
||||
public OutputBuilder clearPlanValid() {
|
||||
return put("plan_valid", false);
|
||||
}
|
||||
|
||||
public OutputBuilder clearNeedsPlanning() {
|
||||
return put("needs_planning", true);
|
||||
}
|
||||
|
||||
public OutputBuilder clearCurrentStepIndex() {
|
||||
return put("current_step_index", 0);
|
||||
}
|
||||
|
||||
public OutputBuilder clearCurrentStepTitle() {
|
||||
return put("current_step_title", "");
|
||||
}
|
||||
|
||||
public OutputBuilder clearCurrentStepResult() {
|
||||
return put("current_step_result", "");
|
||||
}
|
||||
|
||||
public OutputBuilder clearCompletedResults() {
|
||||
return put("completed_results", List.of());
|
||||
}
|
||||
|
||||
public OutputBuilder clearFinalSummaryThinking() {
|
||||
return put("final_summary_thinking", "");
|
||||
}
|
||||
|
||||
public OutputBuilder clearCurrentStepThinking() {
|
||||
return put("current_step_thinking", "");
|
||||
}
|
||||
|
||||
public Map<String, Object> build() {
|
||||
return map;
|
||||
}
|
||||
|
||||
@ -168,6 +168,49 @@ public final class MateClawStateKeys {
|
||||
/** Source references observed from successful tool results during this run. */
|
||||
public static final String SOURCE_EVIDENCE_LEDGER = "source_evidence_ledger";
|
||||
|
||||
// ===== Persistent goal — cross-turn objective lock-in =====
|
||||
|
||||
/**
|
||||
* Active goal snapshot bound to the conversation; null when no goal.
|
||||
* Injected by {@code buildInitialState} from {@code GoalService.findActiveByConversation}.
|
||||
* Read by GoalEvaluationNode + its dispatcher.
|
||||
*/
|
||||
public static final String ACTIVE_GOAL = "active_goal";
|
||||
|
||||
/**
|
||||
* Map snapshot of the latest evaluation pass (score/gap/decision/...).
|
||||
* Written by GoalEvaluationNode; consumed by the SSE accumulator for
|
||||
* the {@code goal_evaluated} event payload.
|
||||
*/
|
||||
public static final String GOAL_EVALUATION_RESULT = "goal_evaluation_result";
|
||||
|
||||
/**
|
||||
* True when GoalEvaluationNode injected a follow-up prompt and the
|
||||
* dispatcher should re-enter the reasoning loop (or PlanGeneration in
|
||||
* the Plan-Execute graph) instead of terminating to END.
|
||||
*/
|
||||
public static final String GOAL_FOLLOWUP_INJECTED = "goal_followup_injected";
|
||||
|
||||
/**
|
||||
* Follow-up user-message text to append to MESSAGES on graph re-entry.
|
||||
* ReasoningNode (or PlanGenerationNode) reads this on its way in,
|
||||
* appends to MESSAGES, then clears the value so the second pass
|
||||
* cannot double-inject.
|
||||
*/
|
||||
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
|
||||
|
||||
/**
|
||||
* Re-entry guard: GoalEvaluationNode sets this true on its first run
|
||||
* of a graph invocation; the FinalAnswerNode→GoalEvaluation conditional
|
||||
* edge skips re-entering the node when it's already true. Combined with
|
||||
* the dispatcher's followup clearing of FINAL_ANSWER, this bounds
|
||||
* follow-ups to at most one per graph run.
|
||||
*/
|
||||
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
|
||||
|
||||
/** Graph-node identifier for the GoalEvaluationNode. */
|
||||
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";
|
||||
|
||||
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Evaluates whether the agent's latest reply satisfies the goal's exit
|
||||
* criteria. PR2 ships a deterministic "always continue" stub plus a
|
||||
* passthrough completion gate; PR5 wires this through to a real LLM call.
|
||||
*
|
||||
* <p>The split lets PR2 unblock the graph topology + dispatcher work
|
||||
* without committing to a particular evaluator model. The stub is
|
||||
* safe-by-default: it never marks a goal completed and never charges
|
||||
* eval_llm_calls.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class GoalEvaluationService {
|
||||
|
||||
private final GoalProperties properties;
|
||||
|
||||
public GoalEvaluationService(GoalProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one turn's terminal answer against the goal's exit criteria.
|
||||
*
|
||||
* <p>Returns {@link GoalEvaluationResult#fallback(String)} when the
|
||||
* evaluator is unavailable so the calling node can skip the bookkeeping
|
||||
* delta path. PR5 swaps this implementation with a real LLM call;
|
||||
* until then we never block the user on evaluator latency.
|
||||
*/
|
||||
public GoalEvaluationResult evaluate(GoalEntity goal,
|
||||
List<?> recentMessages,
|
||||
String terminalAnswer) {
|
||||
if (goal == null) {
|
||||
return GoalEvaluationResult.fallback("no_goal");
|
||||
}
|
||||
if (terminalAnswer == null || terminalAnswer.isBlank()) {
|
||||
return GoalEvaluationResult.fallback("empty_answer");
|
||||
}
|
||||
// PR2 placeholder: deterministic continue with a passthrough gap.
|
||||
// Real evaluator lands in PR5 — see RFC 48 §3.9.
|
||||
String model = properties.getEvaluatorModel();
|
||||
if (model == null || model.isBlank()) {
|
||||
model = "stub";
|
||||
}
|
||||
log.debug("[GoalEvaluation] stub evaluate goal={} answerChars={} -> decision=continue",
|
||||
goal.getId(), terminalAnswer.length());
|
||||
return new GoalEvaluationResult(
|
||||
0.0,
|
||||
"(stub evaluator — wired in PR5)",
|
||||
GoalEvaluationResult.DECISION_CONTINUE,
|
||||
false,
|
||||
model,
|
||||
0,
|
||||
0L);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decides whether to inject a follow-up user prompt for the next graph
|
||||
* pass. PR2 wires the plumbing; the actual "yes, continue" path defaults
|
||||
* to off until PR5 flips {@code mateclaw.goal.enabled=true} and operators
|
||||
* opt their goals in via {@code auto_followup_enabled}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class GoalFollowupService {
|
||||
|
||||
/**
|
||||
* Build the follow-up prompt to inject, or empty when no follow-up
|
||||
* should fire this turn. Conditions follow RFC 48 §3.10:
|
||||
* <ol>
|
||||
* <li>{@code autoFollowupEnabled} is true.</li>
|
||||
* <li>Evaluator decision is "continue" with score < 0.95.</li>
|
||||
* <li>Cooldown since the last follow-up has elapsed.</li>
|
||||
* <li>turn_budget has at least one slot left after this turn.</li>
|
||||
* <li>(agent + eval) LLM calls below 90 % of llm_call_budget.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public Optional<String> maybeBuildFollowup(GoalEntity goal,
|
||||
GoalEvaluationResult result) {
|
||||
if (goal == null || result == null) return Optional.empty();
|
||||
if (!Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) return Optional.empty();
|
||||
if (!GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (result.score() >= 0.95) return Optional.empty();
|
||||
|
||||
// Cooldown — last_followup_at recorded by recordFollowupInjected().
|
||||
Integer cooldownSec = goal.getFollowupCooldownSeconds();
|
||||
if (cooldownSec != null && cooldownSec > 0 && goal.getLastFollowupAt() != null) {
|
||||
Duration since = Duration.between(goal.getLastFollowupAt(), LocalDateTime.now());
|
||||
if (since.getSeconds() < cooldownSec) {
|
||||
log.debug("[GoalFollowup] cooldown not elapsed: {}s < {}s", since.getSeconds(), cooldownSec);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
int turnsUsed = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0;
|
||||
int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE;
|
||||
// Leave at least one turn slot for the real user — refuse to burn
|
||||
// the final slot on an auto-followup that the user can't watch.
|
||||
if (turnsUsed >= turnBudget - 1) return Optional.empty();
|
||||
|
||||
int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE;
|
||||
if (goal.totalLlmCallsUsed() >= (int) (callBudget * 0.9)) return Optional.empty();
|
||||
|
||||
String gap = result.gap();
|
||||
if (gap == null || gap.isBlank()) gap = "the goal is not yet complete.";
|
||||
String prompt = "Continue working on the goal. Still missing: " + gap
|
||||
+ "\nTake the next concrete step.";
|
||||
return Optional.of(prompt);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
/**
|
||||
* Which graph topology a {@code GoalEvaluationNode} instance is wired into.
|
||||
*
|
||||
* <p>The same node class serves both graphs but needs to know how to
|
||||
* clear graph-specific state on follow-up: ReAct only touches
|
||||
* {@code FINAL_ANSWER} + {@code FINISH_REASON}, while Plan-Execute has
|
||||
* a wider set of mid-pass + terminal state to wipe before a re-plan.
|
||||
*
|
||||
* <p>Builder-time decision; the value is captured in the node constructor
|
||||
* and never read from graph state.
|
||||
*/
|
||||
public enum GraphFlavor {
|
||||
REACT,
|
||||
PLAN_EXECUTE
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package vip.mate.agent.graph.edge;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Verifies the dual-target dispatcher: instances configured for ReAct
|
||||
* route to REASONING_NODE on followup, instances configured for
|
||||
* Plan-Execute route to PLAN_GENERATION_NODE on followup, and both
|
||||
* route to END otherwise.
|
||||
*/
|
||||
class GoalEvaluationDispatcherTest {
|
||||
|
||||
private OverAllState stateWith(boolean followup) {
|
||||
OverAllState s = mock(OverAllState.class);
|
||||
// The dispatcher only reads GOAL_FOLLOWUP_INJECTED; everything else
|
||||
// can stay default.
|
||||
lenient().when(s.value("goal_followup_injected", false)).thenReturn(followup);
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactInstance_routesFollowupToReasoning() throws Exception {
|
||||
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__");
|
||||
assertEquals("reasoning", d.apply(stateWith(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactInstance_routesTerminalToEnd() throws Exception {
|
||||
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__");
|
||||
assertEquals("__END__", d.apply(stateWith(false)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void planExecuteInstance_routesFollowupToPlanGeneration() throws Exception {
|
||||
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__");
|
||||
assertEquals("plan_generation", d.apply(stateWith(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void planExecuteInstance_routesTerminalToEnd() throws Exception {
|
||||
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__");
|
||||
assertEquals("__END__", d.apply(stateWith(false)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package vip.mate.agent.graph.state;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Pins the bridge that lets GoalEvaluationNode work uniformly across
|
||||
* graph flavors:
|
||||
* <ul>
|
||||
* <li>ReAct path: FinalAnswerNode writes FINAL_ANSWER.</li>
|
||||
* <li>Plan-Execute long path: PlanSummaryNode writes FINAL_SUMMARY.</li>
|
||||
* <li>Plan-Execute short path: DirectAnswerNode writes DIRECT_ANSWER.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class MateClawStateAccessorTerminalAnswerTest {
|
||||
|
||||
private OverAllState mockState(String finalAnswer, String finalSummary, String directAnswer) {
|
||||
OverAllState s = mock(OverAllState.class);
|
||||
lenient().when(s.value(eq("final_answer"), eq(""))).thenReturn(finalAnswer);
|
||||
lenient().when(s.value(eq("final_summary"), eq(""))).thenReturn(finalSummary);
|
||||
lenient().when(s.value(eq("direct_answer"), eq(""))).thenReturn(directAnswer);
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactPath_returnsFinalAnswer() {
|
||||
OverAllState s = mockState("ReAct answer", "", "");
|
||||
assertEquals("ReAct answer", new MateClawStateAccessor(s).terminalAnswer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void planExecuteLongPath_returnsFinalSummary() {
|
||||
OverAllState s = mockState("", "Plan summary text", "");
|
||||
assertEquals("Plan summary text", new MateClawStateAccessor(s).terminalAnswer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void planExecuteShortPath_returnsDirectAnswer() {
|
||||
OverAllState s = mockState("", "", "Direct quick answer");
|
||||
assertEquals("Direct quick answer", new MateClawStateAccessor(s).terminalAnswer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allEmpty_returnsEmptyString() {
|
||||
OverAllState s = mockState("", "", "");
|
||||
assertEquals("", new MateClawStateAccessor(s).terminalAnswer());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalAnswerWins_overFinalSummary() {
|
||||
// Defensive: if both happen to be populated (shouldn't, but state
|
||||
// is shared across graphs in tests), FINAL_ANSWER takes priority.
|
||||
OverAllState s = mockState("ReAct answer", "stale summary", "");
|
||||
assertEquals("ReAct answer", new MateClawStateAccessor(s).terminalAnswer());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package vip.mate.architecture;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* Strict double-registration check for the persistent-goal state keys.
|
||||
*
|
||||
* <p>{@link StateKeyRegistrationCoverageTest} only verifies that a key
|
||||
* appears somewhere in {@code AgentGraphBuilder.java}; it cannot tell
|
||||
* apart the ReAct and Plan-Execute {@code KeyStrategyFactory} blocks.
|
||||
* History (the {@code CHAT_ORIGIN} regression) shows that "registered
|
||||
* once" is not enough — multi-node merges silently drop keys when one
|
||||
* graph's factory leaves them out.
|
||||
*
|
||||
* <p>Each of the five Goal state keys must therefore appear at least
|
||||
* twice in the builder source: once per graph. This test pins that
|
||||
* invariant so future regressions get caught at PR time.
|
||||
*/
|
||||
class GoalStateKeyDoubleRegistrationTest {
|
||||
|
||||
private static final String[] GOAL_KEYS = {
|
||||
"ACTIVE_GOAL",
|
||||
"GOAL_EVALUATION_RESULT",
|
||||
"GOAL_FOLLOWUP_INJECTED",
|
||||
"GOAL_FOLLOWUP_PROMPT",
|
||||
"GOAL_EVALUATED_THIS_RUN",
|
||||
};
|
||||
|
||||
@Test
|
||||
void everyGoalKeyMustAppearAtLeastTwiceInAddStrategyCalls() throws Exception {
|
||||
Path src = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java")
|
||||
.toAbsolutePath();
|
||||
if (!Files.exists(src)) {
|
||||
fail("Cannot find AgentGraphBuilder.java at " + src);
|
||||
}
|
||||
String content = Files.readString(src);
|
||||
|
||||
for (String key : GOAL_KEYS) {
|
||||
Pattern p = Pattern.compile(
|
||||
"\\.addStrategy\\(\\s*MateClawStateKeys\\." + key + "\\b");
|
||||
Matcher m = p.matcher(content);
|
||||
int count = 0;
|
||||
while (m.find()) count++;
|
||||
if (count < 2) {
|
||||
fail("Goal state key " + key + " must be registered in BOTH the "
|
||||
+ "ReAct and Plan-Execute KeyStrategyFactory blocks "
|
||||
+ "(found " + count + " addStrategy occurrence(s) in "
|
||||
+ "AgentGraphBuilder.java). The architecture coverage "
|
||||
+ "test only checks 'appears somewhere'; this test "
|
||||
+ "is the strict double-registration guard documented "
|
||||
+ "in RFC 48 §3.2 v2.");
|
||||
}
|
||||
assertTrue(count >= 2,
|
||||
"Sanity: " + key + " should have >=2 addStrategy calls");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void goalEvaluationNodeIdentifierAppearsExactlyOncePerGraph() throws Exception {
|
||||
Path src = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java")
|
||||
.toAbsolutePath();
|
||||
String content = Files.readString(src);
|
||||
Pattern p = Pattern.compile(
|
||||
"\\.addNode\\(\\s*MateClawStateKeys\\.GOAL_EVALUATION_NODE\\b");
|
||||
Matcher m = p.matcher(content);
|
||||
int count = 0;
|
||||
while (m.find()) count++;
|
||||
assertEquals(2, count,
|
||||
"GOAL_EVALUATION_NODE must be added as a node in BOTH graphs " +
|
||||
"(ReAct + Plan-Execute). Found " + count + " addNode calls.");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.goal.model.GoalStatus;
|
||||
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Covers the PR2 stub evaluator: deterministic continue + safe fallback
|
||||
* paths. The LLM-backed evaluator lands in PR5; until then any "real"
|
||||
* evaluation always returns continue without charging eval_llm_calls.
|
||||
*/
|
||||
class GoalEvaluationServiceTest {
|
||||
|
||||
private final GoalProperties props = new GoalProperties();
|
||||
private final GoalEvaluationService svc = new GoalEvaluationService(props);
|
||||
|
||||
private GoalEntity goal() {
|
||||
GoalEntity g = new GoalEntity();
|
||||
g.setId(1L);
|
||||
g.setTitle("ship the blog");
|
||||
g.setStatus(GoalStatus.ACTIVE);
|
||||
return g;
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullGoal_returnsFallback() {
|
||||
GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything");
|
||||
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
|
||||
assertFalse(r.completed());
|
||||
assertEquals(0, r.llmCallsConsumed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAnswer_returnsFallback() {
|
||||
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "");
|
||||
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalCall_returnsContinue() {
|
||||
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text");
|
||||
assertNotNull(r);
|
||||
assertEquals(GoalEvaluationResult.DECISION_CONTINUE, r.decision());
|
||||
assertFalse(r.completed());
|
||||
assertEquals(0, r.llmCallsConsumed(),
|
||||
"PR2 stub must not charge eval_llm_calls — that path lands in PR5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluatorModel_defaultsToStub_whenPropertyBlank() {
|
||||
props.setEvaluatorModel("");
|
||||
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text");
|
||||
assertEquals("stub", r.evaluatorModel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluatorModel_carriesPropertyValue_whenConfigured() {
|
||||
props.setEvaluatorModel("qwen-turbo");
|
||||
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text");
|
||||
assertEquals("qwen-turbo", r.evaluatorModel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallback_doesNotChargeLlmCalls() {
|
||||
GoalEvaluationResult r = GoalEvaluationResult.fallback("evaluator_unavailable");
|
||||
assertEquals(0, r.llmCallsConsumed());
|
||||
assertFalse(r.completed());
|
||||
assertTrue(r.gap().contains("evaluator unavailable"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.goal.model.GoalStatus;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Covers the five follow-up gating conditions from RFC 48 §3.10. Every
|
||||
* negative case must independently block the follow-up.
|
||||
*/
|
||||
class GoalFollowupServiceTest {
|
||||
|
||||
private final GoalFollowupService svc = new GoalFollowupService();
|
||||
|
||||
private GoalEntity goal(boolean autoEnabled) {
|
||||
GoalEntity g = new GoalEntity();
|
||||
g.setId(1L);
|
||||
g.setTitle("ship");
|
||||
g.setStatus(GoalStatus.ACTIVE);
|
||||
g.setTurnBudget(20);
|
||||
g.setTurnsUsed(5);
|
||||
g.setLlmCallBudget(200);
|
||||
g.setAgentLlmCallsUsed(30);
|
||||
g.setEvalLlmCallsUsed(4);
|
||||
g.setAutoFollowupEnabled(autoEnabled);
|
||||
g.setFollowupCooldownSeconds(0);
|
||||
return g;
|
||||
}
|
||||
|
||||
private GoalEvaluationResult res(double score, String decision) {
|
||||
return new GoalEvaluationResult(
|
||||
score, "missing X",
|
||||
decision, false,
|
||||
"stub", 0, 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAutoFollowup_returnsEmpty() {
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
goal(false),
|
||||
res(0.6, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedDecision_returnsEmpty() {
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
goal(true),
|
||||
res(0.99, GoalEvaluationResult.DECISION_COMPLETED));
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void highScore_returnsEmpty() {
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
goal(true),
|
||||
res(0.96, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cooldownNotElapsed_returnsEmpty() {
|
||||
GoalEntity g = goal(true);
|
||||
g.setFollowupCooldownSeconds(60);
|
||||
g.setLastFollowupAt(LocalDateTime.now().minusSeconds(10));
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cooldownElapsed_allowsFollowup() {
|
||||
GoalEntity g = goal(true);
|
||||
g.setFollowupCooldownSeconds(60);
|
||||
g.setLastFollowupAt(LocalDateTime.now().minusSeconds(120));
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nearTurnBudget_returnsEmpty() {
|
||||
GoalEntity g = goal(true);
|
||||
g.setTurnBudget(20);
|
||||
g.setTurnsUsed(19); // only one slot left — reserved for the real user
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void over90PercentLlmBudget_returnsEmpty() {
|
||||
GoalEntity g = goal(true);
|
||||
g.setLlmCallBudget(100);
|
||||
g.setAgentLlmCallsUsed(85);
|
||||
g.setEvalLlmCallsUsed(10); // total 95 = 95% > 90% guard
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPath_returnsPrompt_containingGap() {
|
||||
Optional<String> out = svc.maybeBuildFollowup(
|
||||
goal(true),
|
||||
res(0.6, GoalEvaluationResult.DECISION_CONTINUE));
|
||||
assertTrue(out.isPresent());
|
||||
assertTrue(out.get().contains("missing X"));
|
||||
assertTrue(out.get().toLowerCase().contains("next concrete step"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user