diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index e4b984c0..7cde390c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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()) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 61463018..475ae58e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index a378b95c..6e0fcd01 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -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; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java new file mode 100644 index 00000000..7ccd2280 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java @@ -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. + * + *

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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java new file mode 100644 index 00000000..23db6a50 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -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. + * + *

Per RFC 48 v3 §3.3, evaluation runs on a settled terminal answer so + * upstream finishReason / evidence checks are already authoritative. The + * node: + *

    + *
  1. Bails out for the "this turn shouldn't count" finishReasons + * (evidence_insufficient, stopped, error_fallback, return_direct, + * max_iterations_reached, plus awaiting_approval).
  2. + *
  3. Otherwise calls the evaluator, persists the + * agent/eval LLM-call deltas + score + gap via GoalService.
  4. + *
  5. 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.
  6. + *
  7. 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).
  8. + *
+ */ +@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 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 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 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 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 data) { + return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index cf04df6b..7710241d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -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; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index fe839d4b..104259e3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -115,6 +115,20 @@ public class PlanGenerationNode implements NodeAction { public Map 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(); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index f4953271..0efdb601 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -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 activeGoal() { + return state.value(ACTIVE_GOAL); + } + + public boolean hasActiveGoal() { + return state.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 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 build() { return map; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index 30e44678..f6fc7ed5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -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 ===== /** diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java new file mode 100644 index 00000000..cd562ba3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java @@ -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. + * + *

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. + * + *

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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java new file mode 100644 index 00000000..245fa879 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java @@ -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: + *

    + *
  1. {@code autoFollowupEnabled} is true.
  2. + *
  3. Evaluator decision is "continue" with score < 0.95.
  4. + *
  5. Cooldown since the last follow-up has elapsed.
  6. + *
  7. turn_budget has at least one slot left after this turn.
  8. + *
  9. (agent + eval) LLM calls below 90 % of llm_call_budget.
  10. + *
+ */ + public Optional 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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GraphFlavor.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GraphFlavor.java new file mode 100644 index 00000000..3e8b633e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GraphFlavor.java @@ -0,0 +1,17 @@ +package vip.mate.goal.service; + +/** + * Which graph topology a {@code GoalEvaluationNode} instance is wired into. + * + *

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. + * + *

Builder-time decision; the value is captured in the node constructor + * and never read from graph state. + */ +public enum GraphFlavor { + REACT, + PLAN_EXECUTE +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java new file mode 100644 index 00000000..5d817710 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java @@ -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))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/MateClawStateAccessorTerminalAnswerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/MateClawStateAccessorTerminalAnswerTest.java new file mode 100644 index 00000000..b58588b7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/MateClawStateAccessorTerminalAnswerTest.java @@ -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: + *

    + *
  • ReAct path: FinalAnswerNode writes FINAL_ANSWER.
  • + *
  • Plan-Execute long path: PlanSummaryNode writes FINAL_SUMMARY.
  • + *
  • Plan-Execute short path: DirectAnswerNode writes DIRECT_ANSWER.
  • + *
+ */ +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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java new file mode 100644 index 00000000..71fc80e1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/architecture/GoalStateKeyDoubleRegistrationTest.java @@ -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. + * + *

{@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. + * + *

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."); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java new file mode 100644 index 00000000..458fa972 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java @@ -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")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java new file mode 100644 index 00000000..3082e49b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java @@ -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 out = svc.maybeBuildFollowup( + goal(false), + res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void completedDecision_returnsEmpty() { + Optional out = svc.maybeBuildFollowup( + goal(true), + res(0.99, GoalEvaluationResult.DECISION_COMPLETED)); + assertTrue(out.isEmpty()); + } + + @Test + void highScore_returnsEmpty() { + Optional 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 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 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 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 out = svc.maybeBuildFollowup( + g, res(0.6, GoalEvaluationResult.DECISION_CONTINUE)); + assertTrue(out.isEmpty()); + } + + @Test + void happyPath_returnsPrompt_containingGap() { + Optional 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")); + } +}