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:
+ *
+ *
Bails out for the "this turn shouldn't count" finishReasons
+ * (evidence_insufficient, stopped, error_fallback, return_direct,
+ * max_iterations_reached, plus awaiting_approval).
+ *
Otherwise calls the evaluator, persists the
+ * agent/eval LLM-call deltas + score + gap via GoalService.
+ *
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.
+ *
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).