feat(goal): graph topology + evaluation node wired into ReAct + Plan-Execute

This commit is contained in:
matevip 2026-05-21 14:43:13 +08:00
parent ac6c8a18e3
commit ce74a0ae48
17 changed files with 1143 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -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 -&gt; {@code REASONING_NODE}, terminal -&gt;
* {@code END}) and the Plan-Execute graph (followup -&gt;
* {@code PLAN_GENERATION_NODE}, terminal -&gt; {@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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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