From 1470d07ddd2fd0671069a99799c934e3d64d2fa6 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 28 Sep 2026 04:18:05 -0400 Subject: [PATCH] feat(decision): add guarded decision layer with shadow mode --- .../vip/mate/agent/AgentGraphBuilder.java | 15 + .../agent/graph/node/GoalEvaluationNode.java | 37 ++- .../graph/plan/node/PlanGenerationNode.java | 18 +- .../vip/mate/decision/api/DecisionCodes.java | 13 + .../vip/mate/decision/api/DecisionFacts.java | 15 + .../vip/mate/decision/api/DecisionMode.java | 3 + .../mate/decision/api/DecisionOutcome.java | 3 + .../mate/decision/api/DecisionQuestion.java | 32 ++ .../api/DecisionRecordingException.java | 13 + .../mate/decision/api/DecisionRequest.java | 18 ++ .../vip/mate/decision/api/DecisionScope.java | 10 + .../vip/mate/decision/api/DecisionTicket.java | 3 + .../vip/mate/decision/api/DecisionType.java | 3 + .../vip/mate/decision/api/DecisionValue.java | 18 ++ .../decision/config/DecisionProperties.java | 34 ++ .../mate/decision/core/DecisionService.java | 149 +++++++++ .../decision/provider/DecisionCapability.java | 22 ++ .../decision/provider/DecisionProvider.java | 9 + .../decision/provider/DecisionResult.java | 9 + .../provider/LlmDecisionProvider.java | 11 + .../provider/RuleDecisionProvider.java | 13 + .../mate/decision/record/DecisionRecord.java | 10 + .../decision/record/DecisionRecordStore.java | 9 + .../record/JdbcDecisionRecordStore.java | 92 ++++++ .../service/GoalContinuationSupervisor.java | 40 ++- .../goal/service/GoalDecisionAdapter.java | 98 ++++++ .../mate/goal/service/GoalRunCoordinator.java | 75 ++++- .../mate/goal/service/GoalServiceImpl.java | 10 + .../service/AgentRoutingDecisionAdapter.java | 144 +++++++++ .../planning/service/PlanningService.java | 40 ++- .../team/service/TeamDispatchService.java | 112 ++++++- .../mate/team/service/TeamTaskService.java | 103 +++++- .../TeamWorkerInterventionService.java | 12 +- .../team/service/WorkerDecisionAdapter.java | 101 ++++++ .../vip/mate/team/tool/TeamTasksTool.java | 6 + .../src/main/resources/application.yml | 12 + .../db/migration/h2/V203__decision_record.sql | 35 ++ .../kingbase/V203__decision_record.sql | 35 ++ .../migration/mysql/V203__decision_record.sql | 35 ++ .../main/resources/docs/en/decision-layer.md | 77 +++++ .../main/resources/docs/zh/decision-layer.md | 77 +++++ .../GoalEvaluationNodeContinuationTest.java | 102 ++++++ .../plan/node/PlanGenerationRoutingTest.java | 90 ++++++ .../mate/decision/DecisionContractTest.java | 39 +++ .../mate/decision/DecisionPipelineTest.java | 204 ++++++++++++ .../decision/DecisionRecordStoreTest.java | 149 +++++++++ .../mate/decision/DecisionServiceTest.java | 54 ++++ .../vip/mate/decision/DecisionWiringTest.java | 40 +++ .../GoalContinuationSupervisorTest.java | 54 ++++ .../goal/service/GoalDecisionAdapterTest.java | 199 ++++++++++++ .../goal/service/GoalRunCoordinatorTest.java | 168 ++++++++++ .../mate/goal/service/GoalServiceTest.java | 32 ++ .../AgentRoutingDecisionAdapterTest.java | 119 +++++++ .../service/AgentRoutingTransactionTest.java | 172 ++++++++++ .../team/service/TeamDispatchServiceTest.java | 35 ++ .../team/service/TeamTaskServiceTest.java | 37 +++ .../TeamWorkerInterventionServiceTest.java | 19 +- .../service/WorkerDecisionAdapterTest.java | 70 ++++ .../WorkerDecisionTransactionTest.java | 304 ++++++++++++++++++ .../vip/mate/team/tool/TeamTasksToolTest.java | 11 + pom.xml | 9 + 61 files changed, 3443 insertions(+), 35 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionCodes.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionFacts.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionMode.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionOutcome.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionQuestion.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionRecordingException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionRequest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionScope.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionTicket.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionType.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/api/DecisionValue.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/config/DecisionProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/core/DecisionService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/provider/DecisionCapability.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/provider/DecisionProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/provider/DecisionResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/provider/LlmDecisionProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/provider/RuleDecisionProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/record/DecisionRecord.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/record/DecisionRecordStore.java create mode 100644 mateclaw-server/src/main/java/vip/mate/decision/record/JdbcDecisionRecordStore.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalDecisionAdapter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/planning/service/AgentRoutingDecisionAdapter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/WorkerDecisionAdapter.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V203__decision_record.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V203__decision_record.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V203__decision_record.sql create mode 100644 mateclaw-server/src/main/resources/docs/en/decision-layer.md create mode 100644 mateclaw-server/src/main/resources/docs/zh/decision-layer.md create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationRoutingTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/decision/DecisionContractTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/decision/DecisionPipelineTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/decision/DecisionRecordStoreTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/decision/DecisionServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/decision/DecisionWiringTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/service/GoalDecisionAdapterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/planning/service/AgentRoutingDecisionAdapterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/planning/service/AgentRoutingTransactionTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/WorkerDecisionAdapterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/WorkerDecisionTransactionTest.java 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