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 3eedbc9e..5ad7e9b4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1,5 +1,8 @@ package vip.mate.agent; +import vip.mate.goal.service.GoalDecisionAdapter; +import vip.mate.planning.service.AgentRoutingDecisionAdapter; + // PR-0b: DashScope imports moved with the construction code into DashScopeChatModelBuilder. import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.CompileConfig; @@ -159,6 +162,15 @@ public class AgentGraphBuilder { private final vip.mate.goal.service.GoalEvaluationService goalEvaluationService; private final vip.mate.goal.service.GoalFollowupService goalFollowupService; private final vip.mate.goal.config.GoalProperties goalProperties; + private GoalDecisionAdapter goalDecisionAdapter; + private AgentRoutingDecisionAdapter routingDecisionAdapter; + + @Autowired(required = false) + public void setRoutingDecisionAdapter(AgentRoutingDecisionAdapter adapter) { this.routingDecisionAdapter = adapter; } + + + @Autowired(required = false) + public void setGoalDecisionAdapter(GoalDecisionAdapter adapter) { this.goalDecisionAdapter = adapter; } /** C4: per-conversation environment notification registry, injected into ReasoningNode. */ private final vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry; @@ -702,6 +714,7 @@ public class AgentGraphBuilder { // Team hand-off: a lead-of-team plan agent parks multi-step plans on // the team task board instead of the serial delegation pipeline. planGenerationNode.setTeamPlanBridge(teamPlanBridge); + planGenerationNode.setRoutingAdapter(routingDecisionAdapter); List advertisedCallbacks = toolDisclosureService .split(toolSet, Set.of(), autoDemotedTools).activeCallbacks(); AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks( @@ -840,6 +853,7 @@ public class AgentGraphBuilder { goalEvaluationService, goalFollowupService, goalService, goalProperties, conversationWindowManager, conversationService, vip.mate.goal.service.GraphFlavor.PLAN_EXECUTE); + goalEvalNode.setDecisionAdapter(goalDecisionAdapter); StateGraph graph = new StateGraph("plan-execute-agent", keyStrategyFactory) .addNode(PlanStateKeys.PLAN_GENERATION_NODE, @@ -1182,6 +1196,7 @@ public class AgentGraphBuilder { goalEvaluationService, goalFollowupService, goalService, goalProperties, conversationWindowManager, conversationService, vip.mate.goal.service.GraphFlavor.REACT); + goalEvalNode.setDecisionAdapter(goalDecisionAdapter); StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory) .addNode(MateClawStateKeys.REASONING_NODE, 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 index f3975e6d..807f2fcd 100644 --- 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 @@ -14,6 +14,11 @@ import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; import vip.mate.goal.model.GoalResponse; import vip.mate.goal.service.GoalEvaluationService; +import vip.mate.goal.service.GoalDecisionAdapter; +import vip.mate.goal.model.GoalContinuationDecision.Action; +import vip.mate.decision.api.DecisionMode; +import vip.mate.decision.api.DecisionRecordingException; +import java.time.LocalDateTime; import vip.mate.goal.service.GoalFollowupService; import vip.mate.goal.service.GoalService; import vip.mate.goal.service.GraphFlavor; @@ -73,6 +78,10 @@ public class GoalEvaluationNode implements NodeAction { this.flavor = flavor; } + private GoalDecisionAdapter decisionAdapter; + + public void setDecisionAdapter(GoalDecisionAdapter decisionAdapter) { this.decisionAdapter = decisionAdapter; } + @Override public Map apply(OverAllState state) throws Exception { // Master kill switch — when disabled the node stays inert. @@ -230,6 +239,8 @@ public class GoalEvaluationNode implements NodeAction { .build(); } } catch (Throwable t) { + DecisionRecordingException recording = DecisionRecordingException.find(t); + if (recording != null) throw recording; log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}", refreshed.getId(), t.toString()); Map outward = result.toMap(); @@ -266,12 +277,14 @@ public class GoalEvaluationNode implements NodeAction { int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(), GoalProperties.MAX_HARD_CONTINUATIONS_CEILING); Optional followup; + boolean planningSucceeded = true; try { followup = followupService.maybeBuildFollowup(refreshed, result); } catch (Throwable t) { log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}", refreshed.getId(), t.toString()); followup = Optional.empty(); + planningSucceeded = false; } // Per-run safety net: cap the autonomous self-continuation loop so a // single user message can't drive an unbounded number of steps or @@ -286,6 +299,16 @@ public class GoalEvaluationNode implements NodeAction { // recursion ceiling. hardCap==0 keeps the legacy behaviour (a // max-iterations turn simply ends the run). boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap; + // Caps are deterministic guards and must be resolved before consulting a provider. + GoalDecisionAdapter.Selection selection = decisionAdapter == null || !planningSucceeded ? null + : decisionAdapter.selectGraph(refreshed, followup, LocalDateTime.now(), perRunCapReached || hardCapReached); + boolean followupSuppressed = selection != null && selection.ticket().mode() == DecisionMode.ACTIVE + && followup.isPresent() && !perRunCapReached && !hardCapReached + && (selection.decision().action() == Action.DEFER || selection.decision().action() == Action.RETRY); + if (selection != null && selection.ticket().mode() == DecisionMode.ACTIVE) { + followup = selection.decision().action() == Action.CONTINUE + ? Optional.of(selection.decision().prompt()) : Optional.empty(); + } if (followup.isPresent() && (perRunCapReached || hardCapReached)) { log.info("[GoalEvaluationNode] follow-up suppressed for goal={} " + "(followups {}/{}, hardContinuations {}/{}, iterationCapReached={}); ending this run", @@ -363,12 +386,15 @@ public class GoalEvaluationNode implements NodeAction { .clearFinalSummaryThinking() .clearCurrentStepThinking(); } - return out.build(); + Map output = out.build(); + // This records submission to the graph, not durable execution of the next pass. + // A database commit and delivery of in-memory graph output are not atomic. + if (selection != null) decisionAdapter.outcome(selection.ticket(), true, "FOLLOWUP_SUBMITTED"); + return output; } - // Continue but no follow-up — just record the evaluation event. // (helper below avoids needing a custom() factory on GraphEventPublisher.) - return MateClawStateAccessor.output() + Map output = MateClawStateAccessor.output() .goalEvaluationResult(result.toMap()) .goalEvaluatedThisRun(true) .events(List.of(goalEvent("goal_evaluated", Map.of( @@ -377,6 +403,11 @@ public class GoalEvaluationNode implements NodeAction { "gap", result.gap() == null ? "" : result.gap(), "goal", stateSafeGoal(goalService.toResponse(refreshed)))))) .build(); + // Graph DEFER/RETRY applies suppression of this pass only; it creates no durable schedule. + if (selection != null) decisionAdapter.outcome(selection.ticket(), followupSuppressed, + followupSuppressed ? "FOLLOWUP_SUPPRESSED" + : perRunCapReached || hardCapReached ? "GRAPH_CAP" : "NO_FOLLOWUP"); + return output; } /** 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 26b9f1d3..e39f6fae 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 @@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.service.GoalService; import vip.mate.planning.service.PlanningService; +import vip.mate.planning.service.AgentRoutingDecisionAdapter; +import vip.mate.decision.api.DecisionRecordingException; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.service.TeamPlanBridge; @@ -78,6 +80,10 @@ public class PlanGenerationNode implements NodeAction { * pipeline (non-team deployments / tests). */ private TeamPlanBridge teamPlanBridge; + private AgentRoutingDecisionAdapter routingAdapter; + + public void setRoutingAdapter(AgentRoutingDecisionAdapter adapter) { this.routingAdapter = adapter; } + public void setTeamPlanBridge(TeamPlanBridge teamPlanBridge) { this.teamPlanBridge = teamPlanBridge; @@ -807,10 +813,16 @@ public class PlanGenerationNode implements NodeAction { List stepAgentIds = resolveStepAgents(steps, triage != null ? triage.stepAgents() : null, chatOrigin.workspaceId(), agentId); - var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds); + // A Team whose roster resolution failed must still bypass non-Team routing. + var routing = leadTeam == null && routingAdapter != null && routingAdapter.enabled() + ? routingAdapter.select(chatOrigin.workspaceId(), agentId, conversationId, + displayGoal(accessor.goal()), steps.size(), stepAgentIds) : null; + var plan = routing == null + ? planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds) + : planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds, routing); log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}", plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step", - stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : ""); + stepAgentIds != null ? ", planner delegation=" + stepAgentIds : ""); events.add(GraphEventPublisher.planCreated(plan.getId(), steps)); @@ -847,6 +859,8 @@ public class PlanGenerationNode implements NodeAction { return planOut.build(); } catch (Exception e) { + DecisionRecordingException recording = DecisionRecordingException.find(e); + if (recording != null) throw recording; log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e); // When the triage LLM fails or returns unparseable output we now fall back to // a single-step plan (the user's goal verbatim) instead of a direct text diff --git a/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionCodes.java b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionCodes.java new file mode 100644 index 00000000..fea5d896 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionCodes.java @@ -0,0 +1,13 @@ +package vip.mate.decision.api; + +public final class DecisionCodes { + private DecisionCodes() {} + public static String requireCode(String value) { + if (value == null || !value.matches("[A-Za-z0-9_.:-]{1,80}")) throw new IllegalArgumentException("Invalid decision code"); + return value; + } + public static String description(String value) { + if (value == null || value.isBlank() || value.length() > 2048) throw new IllegalArgumentException("Invalid description size"); + return value; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionFacts.java b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionFacts.java new file mode 100644 index 00000000..6d018a04 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionFacts.java @@ -0,0 +1,15 @@ +package vip.mate.decision.api; + +import java.util.List; +import java.util.Map; + +public record DecisionFacts(Map flags, Map numbers, List evidence) { + public DecisionFacts(Map flags, Map numbers) { this(flags, numbers, List.of()); } + public DecisionFacts { + flags = Map.copyOf(flags); numbers = Map.copyOf(numbers); evidence = List.copyOf(evidence); + if (flags.size() + numbers.size() > 64 || evidence.size() > 8 || evidence.stream().mapToInt(String::length).sum() > 8192) + throw new IllegalArgumentException("Facts exceed limits"); + flags.keySet().forEach(DecisionCodes::requireCode); numbers.keySet().forEach(DecisionCodes::requireCode); + if (numbers.values().stream().anyMatch(n -> !Double.isFinite(n))) throw new IllegalArgumentException("Nonfinite fact"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionMode.java b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionMode.java new file mode 100644 index 00000000..29b59034 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionMode.java @@ -0,0 +1,3 @@ +package vip.mate.decision.api; + +public enum DecisionMode { OFF, SHADOW, ACTIVE } diff --git a/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionOutcome.java b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionOutcome.java new file mode 100644 index 00000000..94059483 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionOutcome.java @@ -0,0 +1,3 @@ +package vip.mate.decision.api; + +public enum DecisionOutcome { PENDING, OBSERVED, APPLIED, NOT_APPLIED } diff --git a/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionQuestion.java b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionQuestion.java new file mode 100644 index 00000000..58e3d092 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/decision/api/DecisionQuestion.java @@ -0,0 +1,32 @@ +package vip.mate.decision.api; + +import java.util.List; +import java.util.HashSet; + +public sealed interface DecisionQuestion { + String version(); + String description(); + boolean accepts(DecisionValue value); + record Option(String code, String description) { + public Option { DecisionCodes.requireCode(code); DecisionCodes.description(description); } + } + record Choice(String version, String description, List