From 17c87dfa3ae8750b9cddeab9f1754d091fa840b3 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 29 Sep 2026 05:26:39 -0400 Subject: [PATCH] fix(decision): enforce worker contracts and bound goal recovery --- .../vip/mate/agent/AgentGraphBuilder.java | 4 +- .../agent/graph/node/GoalEvaluationNode.java | 24 ++++++++ .../plan/edge/StepProgressDispatcher.java | 11 ++++ .../plan/node/DelegatedStepContract.java | 22 +------ .../graph/plan/node/PlanGenerationNode.java | 29 ++++++++-- .../graph/plan/node/StepExecutionNode.java | 17 +++++- .../agent/runtime/WorkerResultContract.java | 40 +++++++++++++ .../mate/decision/core/DecisionService.java | 2 + .../goal/service/GoalDecisionAdapter.java | 7 +++ .../goal/service/GoalEvaluationService.java | 22 +++++-- .../vip/mate/goal/service/GoalService.java | 4 ++ .../mate/goal/service/GoalServiceImpl.java | 31 ++++++++++ .../team/service/TeamDispatchService.java | 20 +++++++ .../mate/tool/builtin/GoalManagementTool.java | 19 ++++++- .../GoalEvaluationNodeContinuationTest.java | 57 +++++++++++++++++++ .../plan/edge/StepProgressDispatcherTest.java | 11 ++++ .../plan/node/DelegatedStepContractTest.java | 13 +++++ .../plan/node/PlanGenerationAutoGoalTest.java | 13 +++++ .../goal/GoalPersistenceIntegrationTest.java | 27 +++++++++ .../service/GoalEvaluationServiceTest.java | 17 ++++++ .../mate/goal/service/GoalServiceTest.java | 40 +++++++++++++ .../team/service/TeamDispatchServiceTest.java | 32 ++++++++++- .../WorkerDecisionTransactionTest.java | 31 ++++++++-- .../tool/builtin/GoalManagementToolTest.java | 26 +++++++++ 24 files changed, 479 insertions(+), 40 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/WorkerResultContract.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 a36f521a..45a1c44b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -715,6 +715,7 @@ public class AgentGraphBuilder { // the team task board instead of the serial delegation pipeline. planGenerationNode.setTeamPlanBridge(teamPlanBridge); planGenerationNode.setRoutingAdapter(routingDecisionAdapter); + planGenerationNode.setGoalDecisionAdapter(goalDecisionAdapter); List advertisedCallbacks = toolDisclosureService .split(toolSet, Set.of(), autoDemotedTools).activeCallbacks(); AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks( @@ -725,6 +726,7 @@ public class AgentGraphBuilder { // Per-step delegation: route a step assigned to a specialist agent // through DelegateAgentTool (null when delegation deps aren't wired). stepExecutionNode.setDelegateAgentTool(delegateAgentTool); + stepExecutionNode.setGoalDecisionAdapter(goalDecisionAdapter); PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper); planSummaryNode.setGoalService(goalService); DirectAnswerNode directAnswerNode = new DirectAnswerNode(); @@ -877,7 +879,7 @@ public class AgentGraphBuilder { // rebuilt from team tasks — summarize directly. PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE)) .addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE, - AsyncEdgeAction.edge_async(new StepProgressDispatcher()), + AsyncEdgeAction.edge_async(new StepProgressDispatcher(goalService)), Map.of( PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_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 807f2fcd..c4158b49 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 @@ -175,6 +175,21 @@ public class GoalEvaluationNode implements NodeAction { GoalEntity refreshed; try { result = evaluationService.evaluate(goal, recent, terminal); + // Retry only the judge against identical evidence, never the business execution. + int pendingAgentCalls = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount()); + int budget = goal.getLlmCallBudget() == null ? 0 : goal.getLlmCallBudget(); + boolean evaluationRoom = budget <= 0 + || goal.totalLlmCallsUsed() + pendingAgentCalls + result.llmCallsConsumed() < budget; + if (GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision()) + && "evaluator unavailable: empty_response".equals(result.gap()) + && !goal.isJsonAcceptanceRequired() + && evaluationRoom && decisionAdapter != null && decisionAdapter.enabled()) { + var retry = evaluationService.evaluate(goal, recent, terminal); + result = new GoalEvaluationResult(retry.score(), retry.gap(), retry.decision(), retry.completed(), + retry.evaluatorModel(), result.llmCallsConsumed() + retry.llmCallsConsumed(), + result.latencyMs() + retry.latencyMs(), retry.criterionVerdicts(), + retry.bootstrapCriteria(), retry.evaluationRevision()); + } // Bill only the NEW agent LLM calls since the last accounted point. // The run-to-completion loop evaluates multiple times per graph run @@ -203,6 +218,15 @@ public class GoalEvaluationNode implements NodeAction { .events(List.of(skippedEvent(refreshed.getId(), "goal_no_longer_active"))).build(); } + if (GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision()) + && goalService.suspendRuntime(goal.getId(), "EVALUATION_UNAVAILABLE")) { + return MateClawStateAccessor.output().goalEvaluationResult(result.toMap()).goalEvaluatedThisRun(true) + .events(List.of(goalEvent("goal_paused", Map.of("goalId", String.valueOf(goal.getId()), + "reason", "EVALUATION_UNAVAILABLE", + "goal", stateSafeGoal(goalService.toResponse(goalService.getById(goal.getId()))))))) + .build(); + } + // Decision branches. Each terminal write is wrapped so a DB hiccup // (e.g. optimistic-lock conflict exceeding retries, memory sync // failure on completion) does not propagate into the chat graph diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java index 0d15a188..c00af56a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcher.java @@ -21,12 +21,23 @@ import java.util.List; * @author MateClaw Team */ public class StepProgressDispatcher implements EdgeAction { + private final vip.mate.goal.service.GoalService goals; + public StepProgressDispatcher() { this(null); } + public StepProgressDispatcher(vip.mate.goal.service.GoalService goals) { this.goals = goals; } + @Override @SuppressWarnings("unchecked") public String apply(OverAllState state) { // 审批暂停态或步骤执行失败中止态:直接结束当前图 tick String currentPhase = state.value(MateClawStateKeys.CURRENT_PHASE, ""); + if ("plan_aborted".equals(currentPhase) && goals != null) { + String conversation = state.value(MateClawStateKeys.CONVERSATION_ID, ""); + if (!conversation.isBlank()) { + var goal = goals.findActiveByConversation(conversation); + if (goal != null) goals.suspendRuntime(goal.getId(), "PLAN_ABORTED"); + } + } if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) { return StateGraph.END; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DelegatedStepContract.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DelegatedStepContract.java index 11fb80ec..576154c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DelegatedStepContract.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/DelegatedStepContract.java @@ -45,25 +45,7 @@ final class DelegatedStepContract { } } - record Result(boolean completed, String text, String reason) {} - - static Result parse(String reply) { - if (reply == null || reply.length() > 4000) return invalid(); - try { - var value = JSON.readTree(reply); - if (value == null || !value.isObject() || value.size() != 3 - || !value.path("status").isTextual() || !value.path("result").isTextual() - || !value.path("evidence").isTextual()) return invalid(); - String status = value.path("status").asText(); - if (!Set.of("COMPLETED", "BLOCKED", "FAILED").contains(status)) return invalid(); - String result = value.path("result").asText().trim(); - String evidence = value.path("evidence").asText().trim(); - if (evidence.isEmpty() || ("COMPLETED".equals(status) && result.isEmpty())) return invalid(); - return new Result("COMPLETED".equals(status), result, status); - } catch (java.io.IOException error) { - return invalid(); - } + static vip.mate.agent.runtime.WorkerResultContract.Result parse(String reply) { + return vip.mate.agent.runtime.WorkerResultContract.parse(reply, 4000); } - - private static Result invalid() { return new Result(false, "", "INVALID_RESULT_CONTRACT"); } } 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 1715022e..70ed4d46 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 @@ -70,6 +70,11 @@ public class PlanGenerationNode implements NodeAction { /** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */ private final GoalService goalService; private final GoalProperties goalProperties; + private vip.mate.goal.service.GoalDecisionAdapter goalDecisionAdapter; + public void setGoalDecisionAdapter(vip.mate.goal.service.GoalDecisionAdapter adapter) { + this.goalDecisionAdapter = adapter; + } + /** Optional — advertise delegatable specialist agents to the planner and * resolve per-step assignments. Null disables per-step delegation (legacy/test). */ private final AgentService agentService; @@ -302,8 +307,8 @@ public class PlanGenerationNode implements NodeAction { * Auto-derive a goal from a freshly-generated multi-step plan so the * Plan-Execute path engages the goal subsystem (the planner / step executor * never call {@code setGoal} themselves). The plan steps become the goal's - * acceptance criteria — the plan IS the decomposition — so the first - * evaluation skips the bootstrap round and judges those criteria directly. + * acceptance criteria in legacy mode. Enabled decision modes preserve the original + * objective so model-generated steps cannot relax the user's completion requirements. * *

Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS * run's GoalEvaluationNode picks it up) or {@code null} when not applicable: @@ -337,10 +342,14 @@ public class PlanGenerationNode implements NodeAction { : request.length() > AUTO_GOAL_TITLE_MAX ? request.substring(0, AUTO_GOAL_TITLE_MAX) : request); req.setDescription(request); - List criteria = steps.stream() - .filter(s -> s != null && !s.isBlank()) - .map(s -> new GoalCriterion("", s.strip(), false, "")) - .collect(Collectors.toList()); + boolean preserveObjective = goalDecisionAdapter != null && goalDecisionAdapter.enabled() + && !request.isBlank(); + List criteria = preserveObjective + ? List.of(new GoalCriterion("", request, false, "")) + : steps.stream().filter(s -> s != null && !s.isBlank()) + .map(s -> new GoalCriterion("", s.strip(), false, "")) + .collect(Collectors.toList()); + if (preserveObjective) req.setExitCriteria(request); if (!criteria.isEmpty()) { req.setCriteria(criteria); } @@ -658,6 +667,14 @@ public class PlanGenerationNode implements NodeAction { + workingContext)); } + if (goalDecisionAdapter != null && goalDecisionAdapter.enabled()) { + promptMessages.add(new SystemMessage(""" + 保留用户要求的实际交付标准。缺少必要输入时,步骤仍要求原交付物,并明确报告 BLOCKED。 + 不得自行添加“无法计算则说明原因也算完成”等替代验收条件。 + 阻塞说明是停止信号,不是用户要求的计算结果;只有用户原本要求解释原因时才可作为交付。 + 不要把创建目标、等待输入、计划生成当成最终交付。 + """)); + } promptMessages.add(new UserMessage("用户目标:" + goal)); // Append JSON schema hint generated by BeanOutputConverter so the LLM diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 5d9b5c71..c584c50a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -82,6 +82,11 @@ public class StepExecutionNode implements NodeAction { * specialist agent runs on that agent. Null disables per-step delegation. */ private DelegateAgentTool delegateAgentTool; + private vip.mate.goal.service.GoalDecisionAdapter goalDecisionAdapter; + public void setGoalDecisionAdapter(vip.mate.goal.service.GoalDecisionAdapter adapter) { + this.goalDecisionAdapter = adapter; + } + public void setDelegateAgentTool(DelegateAgentTool delegateAgentTool) { this.delegateAgentTool = delegateAgentTool; @@ -758,7 +763,17 @@ public class StepExecutionNode implements NodeAction { // Keep the rolling working-context in sync exactly like the local path // so later steps see this delegated step's result. String prevWorkingContext = accessor.workingContext(); - String formattedNewStep = formatStepResult(stepIndex, finalResult); + String summaryResult = finalResult; + if (goalDecisionAdapter != null && goalDecisionAdapter.enabled()) { + String name = java.util.Objects.toString(childResult.agentName(), ""); + if (name.length() > 160) name = name.substring(0, 160); + // Runtime metadata comes from the actual child invocation, not generated prose. + // Keep bounded evidence so summary/evaluation need not guess who ran this step. + String identity = MAPPER.valueToTree(Map.of("assignedAgentId", String.valueOf(assignedAgentId), + "assignedAgentName", name, "status", "COMPLETED")).toString(); + summaryResult = "[Runtime execution evidence] " + identity + "\n" + finalResult; + } + String formattedNewStep = formatStepResult(stepIndex, summaryResult); String updatedWorkingContext = prevWorkingContext.isEmpty() ? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep)) : appendStepIncremental(prevWorkingContext, formattedNewStep); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/WorkerResultContract.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/WorkerResultContract.java new file mode 100644 index 00000000..ca489c77 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/WorkerResultContract.java @@ -0,0 +1,40 @@ +package vip.mate.agent.runtime; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Set; + +/** Structured business outcome, independent of whether the model transport succeeded. */ +public final class WorkerResultContract { + private WorkerResultContract() {} + private static final ObjectMapper JSON = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + public static final String INSTRUCTIONS = """ + Return ONLY a JSON object, without markdown: + {"status":"COMPLETED|BLOCKED|FAILED","result":"deliverable summary","evidence":"concrete evidence or missing requirement"}. + Choose COMPLETED only when the requested deliverable is actually satisfied. + If inputs are missing, return BLOCKED even if team_tasks is unavailable. + Tool unavailability does not turn a blocked task into a completed task. + """; + public record Result(boolean completed, String text, String reason, String evidence) {} + + public static Result parse(String reply, int maxLength) { + if (reply == null || reply.length() > maxLength) return invalid(); + try { + var value = JSON.readTree(reply); + if (value == null || !value.isObject() || value.size() != 3 + || !value.path("status").isTextual() || !value.path("result").isTextual() + || !value.path("evidence").isTextual()) return invalid(); + String status = value.path("status").asText(); + if (!Set.of("COMPLETED", "BLOCKED", "FAILED").contains(status)) return invalid(); + String result = value.path("result").asText().trim(); + String evidence = value.path("evidence").asText().trim(); + if (evidence.isEmpty() || ("COMPLETED".equals(status) && result.isEmpty())) return invalid(); + return new Result("COMPLETED".equals(status), result, status, evidence); + } catch (java.io.IOException error) { + return invalid(); + } + } + + private static Result invalid() { return new Result(false, "", "INVALID_RESULT_CONTRACT", "Structured worker result required"); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/decision/core/DecisionService.java b/mateclaw-server/src/main/java/vip/mate/decision/core/DecisionService.java index b74721bf..7232b3c1 100644 --- a/mateclaw-server/src/main/java/vip/mate/decision/core/DecisionService.java +++ b/mateclaw-server/src/main/java/vip/mate/decision/core/DecisionService.java @@ -37,6 +37,8 @@ public class DecisionService implements AutoCloseable { return new ThreadPoolExecutor(threads, threads, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(capacity), Thread.ofPlatform().daemon(true).name(prefix, 0).factory(), new ThreadPoolExecutor.AbortPolicy()); } + public boolean enabled(DecisionType type) { return properties.modeFor(type) != DecisionMode.OFF; } + public DecisionTicket decide(DecisionRequest request) { var mode = properties.modeFor(request.type()); metrics.counter("mate.decision.requests", "type", request.type().name(), "mode", mode.name()).increment(); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalDecisionAdapter.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalDecisionAdapter.java index 13a87cb2..b2ab3781 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalDecisionAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalDecisionAdapter.java @@ -33,6 +33,13 @@ public class GoalDecisionAdapter { this.decisions = decisions; } + public boolean enabled() { return decisions.enabled(DecisionType.GOAL_CONTINUATION); } + + /** Runtime blockers are deterministic and cannot be overridden by a provider. */ + public DecisionTicket guardStop(GoalEntity goal, String reasonCode) { + return request(goal, Action.DISABLED, null, reasonCode, true); + } + public record Selection(DecisionTicket ticket, GoalContinuationDecision decision) {} public Selection select(GoalEntity goal, GoalEvaluationResult result, LocalDateTime now, 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 index a75167ef..c834e1f3 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java @@ -64,10 +64,17 @@ public class GoalEvaluationService implements Evaluator { /** * Token budget for the evaluator response. Reasoning-mode models consume - * a chunk of this on internal thinking before emitting JSON; 2000 leaves - * comfort for the reasoning trace plus the small object we need. + * internal thinking before emitting JSON. Keep the old limit in OFF/managed + * flows; ordinary enabled goals need bounded headroom after observed LENGTH exits. */ private static final int MAX_OUTPUT_TOKENS = 2000; + private static final int ENABLED_OUTPUT_TOKENS = 4096; + private vip.mate.decision.config.DecisionProperties decisionProperties; + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setDecisionProperties(vip.mate.decision.config.DecisionProperties properties) { + this.decisionProperties = properties; + } + private static final int MAX_CONVERSATION_CHARS = 6_000; private static final int MAX_TERMINAL_ANSWER_CHARS = 4_000; private static final int MAX_SUCCESS_CHECK_CHARS = 4_000; @@ -146,7 +153,10 @@ public class GoalEvaluationService implements Evaluator { ChatOptions options = ChatOptions.builder() .temperature(0.1) - .maxTokens(MAX_OUTPUT_TOKENS) + .maxTokens(decisionProperties != null && !goal.isJsonAcceptanceRequired() + && decisionProperties.modeFor(vip.mate.decision.api.DecisionType.GOAL_CONTINUATION) + != vip.mate.decision.api.DecisionMode.OFF + ? ENABLED_OUTPUT_TOKENS : MAX_OUTPUT_TOKENS) .build(); ChatResponse response = chatModel.call(new Prompt(messages, options)); @@ -154,7 +164,11 @@ public class GoalEvaluationService implements Evaluator { String body = extractText(response); if (body == null || body.isBlank()) { - log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName()); + log.warn("[GoalEvaluation] empty response: model={}, finishReason={}, totalTokens={}, elapsedMs={}", + model.getModelName(), + response == null || response.getResult() == null ? null : response.getResult().getMetadata().getFinishReason(), + response == null || response.getMetadata() == null || response.getMetadata().getUsage() == null + ? null : response.getMetadata().getUsage().getTotalTokens(), elapsed); // The call was really spent — bill it. return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed) .withEvaluationRevision(evaluationRevision); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index cd058f1e..009b7c4d 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -54,6 +54,10 @@ public interface GoalService { GoalEntity pause(Long id, String username); + /** Stop unchanged runtime work after a deterministic blocker; OFF retains legacy scheduling. */ + boolean suspendRuntime(Long id, String reasonCode); + + /** Pause a persistent active goal until essential user input or permission is provided. */ GoalEntity waitForInput(Long id, String reason, String username); GoalEntity resume(Long id, String username); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 6228631c..637ea274 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -335,6 +335,37 @@ public class GoalServiceImpl implements GoalService { // ==================== State machine ==================== + @Override + @Transactional + public boolean suspendRuntime(Long id, String reasonCode) { + if (decisionAdapter == null) return false; + if (!java.util.Set.of("PLAN_ABORTED", "EVALUATION_UNAVAILABLE").contains(reasonCode)) { + throw new IllegalArgumentException("Unknown runtime stop reason"); + } + GoalEntity goal = getById(id); + // Managed JSON goals have their own owner/lease/approval state machine. + // Generic runtime guardrails must not mutate them on stale or unselected turns. + if (goal.getStatus() != GoalStatus.ACTIVE || goal.isJsonAcceptanceRequired()) return false; + var ticket = decisionAdapter.guardStop(goal, reasonCode); + if (ticket.mode() == vip.mate.decision.api.DecisionMode.OFF) return false; + // Transaction + optimistic version checks keep the transition and its audit atomic. + GoalEntity paused = retryOptimistic(id, "suspendRuntime", fresh -> { + if (fresh.getStatus() != GoalStatus.ACTIVE) { + throw new MateClawException("err.goal.not_active", 409, "Goal is no longer active"); + } + LambdaUpdateWrapper update = baseLockedUpdate(fresh) + .set(GoalEntity::getStatus, GoalStatus.PAUSED) + .set(GoalEntity::getProgressSummary, "Runtime paused: " + reasonCode + "; review and explicitly resume."); + bumpVersionAndTime(update); + return update; + }); + Map detail = Map.of("reason", reasonCode, "from", "active", "to", "paused"); + writeEvent(id, GoalEventType.PAUSED, null, detail); + recordAudit("goal.runtime_paused", paused, detail); + decisionAdapter.outcome(ticket, true, "PAUSED"); + return true; + } + @Override @Transactional public GoalEntity pause(Long id, String username) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index e23fa723..0b3738b6 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -330,6 +330,21 @@ public class TeamDispatchService { return; } if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) { + // Domain guard: a successful model invocation is not a completed business task. + // OFF retains the legacy text contract; SHADOW records the corrected baseline. + if (decisionAdapter != null && decisionAdapter.enabled()) { + var result = vip.mate.agent.runtime.WorkerResultContract.parse(reply, MAX_RESULT_CHARS); + if (!result.completed()) { + String reason = result.reason() + ": " + truncate(result.evidence(), 1000); + var judgment = decisionAdapter.judge(current, result.reason(), reply); + if (taskService.failTask(task.getId(), reason, judgment.ticket(), snapshot)) { + broadcast(task, "team_task_failed", Map.of("reason", reason)); + announceService.announceTaskSettled(taskService.getTask(task.getId())); + } + return; + } + reply = result.text(); + } boolean attachedGeneratedFile = attachGeneratedFileDeliverable(current, reply, snapshot); String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile); if (invalidReason != null) { @@ -589,6 +604,11 @@ public class TeamDispatchService { - If scope is ambiguous but you can make a reasonable assumption, state the assumption and continue. - If you are missing an input you cannot obtain yourself, call team_tasks(action="comment", taskId=%s, type="blocker", text="what you need") and stop. Do not ask the lead or user for clarification in your final reply. """.formatted(task.getId(), task.getId(), task.getId())); + if (decisionAdapter != null && decisionAdapter.enabled()) { + sb.append("\n[Final result contract]\n") + .append(vip.mate.agent.runtime.WorkerResultContract.INSTRUCTIONS) + .append("Keep the entire JSON under ").append(MAX_RESULT_CHARS).append(" characters.\n"); + } return sb.toString(); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index b9dcd320..cdd6bb9a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -42,6 +42,13 @@ public class GoalManagementTool { private final GoalProperties properties; private final ObjectMapper objectMapper; private final ChatStreamTracker streamTracker; + private vip.mate.decision.config.DecisionProperties decisionProperties; + + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setDecisionProperties(vip.mate.decision.config.DecisionProperties properties) { + this.decisionProperties = properties; + } + @Tool(description = """ Set a persistent goal for the current conversation. The agent will \ @@ -62,7 +69,7 @@ public class GoalManagementTool { + "Omit to use the system default.", required = false) Boolean autoFollowup, @ToolParam(description = "Optional initial checklist: a list of short, individually verifiable " - + "acceptance criteria faithful to the requested final deliverable, not intermediate setup actions. Omit to let the system derive the checklist on first evaluation.", + + "acceptance criteria faithful to the requested final deliverable, not intermediate setup actions. When exitCriteria is supplied, it is authoritative in enabled decision modes. Omit to let the system derive the checklist on first evaluation.", required = false) java.util.List criteria, @Nullable ToolContext ctx) { @@ -90,7 +97,15 @@ public class GoalManagementTool { req.setExitCriteria(exitCriteria); if (turnBudget != null) req.setTurnBudget(turnBudget); if (autoFollowup != null) req.setAutoFollowupEnabled(autoFollowup); - if (criteria != null && !criteria.isEmpty()) { + // One authoritative definition: do not let a competing model-generated checklist + // add setup/waiting actions to an explicitly supplied delivery condition. + if (decisionProperties != null + && decisionProperties.modeFor(vip.mate.decision.api.DecisionType.GOAL_CONTINUATION) + != vip.mate.decision.api.DecisionMode.OFF + && exitCriteria != null && !exitCriteria.isBlank()) { + req.setCriteria(java.util.List.of(new vip.mate.goal.model.GoalCriterion( + "", exitCriteria.trim(), false, ""))); + } else if (criteria != null && !criteria.isEmpty()) { java.util.List items = new java.util.ArrayList<>(); for (String text : criteria) { if (text != null && !text.isBlank()) { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java index 7d4eb982..7436dd87 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java @@ -68,6 +68,63 @@ class GoalEvaluationNodeContinuationTest { // ===== Pure decision helpers ===== + @Test + void emptyEvaluationDoesNotRetryPastCombinedBudget() throws Exception { + Fixture f = new Fixture(); + var state = f.state(FinishReason.NORMAL.getValue(), 0, 0); + state.value(MateClawStateKeys.ACTIVE_GOAL).orElseThrow().setLlmCallBudget(11); + when(f.evaluationService.evaluate(any(), anyList(), anyString())) + .thenReturn(GoalEvaluationResult.fallbackAfterCall("empty_response", "fixture", 2)); + when(f.goalService.suspendRuntime(1L, "EVALUATION_UNAVAILABLE")).thenReturn(true); + var node = f.node(); node.setDecisionAdapter(mock(GoalDecisionAdapter.class)); + node.apply(state); + verify(f.evaluationService).evaluate(any(), anyList(), anyString()); + verify(f.goalService).recordEvaluation(eq(1L), any(), eq(10), eq(1)); + } + + @Test + void offEmptyEvaluationKeepsSingleJudgeCall() throws Exception { + Fixture f = new Fixture(); + when(f.evaluationService.evaluate(any(), anyList(), anyString())) + .thenReturn(GoalEvaluationResult.fallbackAfterCall("empty_response", "fixture", 2)); + // No adapter and suspendRuntime=false represent the existing OFF domain path. + f.node().apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)); + verify(f.evaluationService).evaluate(any(), anyList(), anyString()); + verify(f.goalService).recordEvaluation(eq(1L), any(), eq(10), eq(1)); + } + + @Test + void emptyEvaluationRetriesOnlyJudgeAndAccountsBothCalls() throws Exception { + Fixture f = new Fixture(); + var adapter = mock(GoalDecisionAdapter.class); when(adapter.enabled()).thenReturn(true); + var first = GoalEvaluationResult.fallbackAfterCall("empty_response", "fixture", 2); + var second = new GoalEvaluationResult(1, "done", "completed", true, "fixture", 1, 3, List.of(), null); + when(f.evaluationService.evaluate(any(), anyList(), anyString())).thenReturn(first, second); + var goal = new GoalEntity(); goal.setId(1L); + when(f.goalService.markRuntimeEvaluatedCompleted(any(), any(), any())).thenReturn(goal); + var node = f.node(); node.setDecisionAdapter(adapter); + var out = node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)); + verify(f.evaluationService, times(2)).evaluate(any(), anyList(), eq("partial answer so far")); + verify(f.goalService).recordEvaluation(eq(1L), any(), eq(10), eq(2)); + verify(f.followupService, never()).maybeBuildFollowup(any(), any()); + assertEquals(true, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)); + } + + @Test + void unavailableJudgePausesInsteadOfReexecutingBusinessWork() throws Exception { + Fixture f = new Fixture(); + var adapter = mock(GoalDecisionAdapter.class); when(adapter.enabled()).thenReturn(true); + when(f.evaluationService.evaluate(any(), anyList(), anyString())) + .thenReturn(GoalEvaluationResult.fallbackAfterCall("empty_response", "fixture", 2)); + when(f.goalService.suspendRuntime(1L, "EVALUATION_UNAVAILABLE")).thenReturn(true); + var node = f.node(); node.setDecisionAdapter(adapter); + var out = node.apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)); + verify(f.evaluationService, times(2)).evaluate(any(), anyList(), anyString()); + verify(f.goalService).suspendRuntime(1L, "EVALUATION_UNAVAILABLE"); + verify(f.followupService, never()).maybeBuildFollowup(any(), any()); + assertEquals(true, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)); + } + @Test void hardSkip_coversUserAndNonProgressTerminals_only() { assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.STOPPED.getValue())); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java index 352bb798..a1f8f035 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/edge/StepProgressDispatcherTest.java @@ -28,6 +28,17 @@ class StepProgressDispatcherTest { return new OverAllState(vals); } + @Test + void abortedPlanSuspendsConversationGoalBeforeEnding() { + var goals = org.mockito.Mockito.mock(vip.mate.goal.service.GoalService.class); + var goal = new vip.mate.goal.model.GoalEntity(); goal.setId(42L); + org.mockito.Mockito.when(goals.findActiveByConversation("conv")).thenReturn(goal); + var state = new OverAllState(Map.of(MateClawStateKeys.CURRENT_PHASE, "plan_aborted", + MateClawStateKeys.CONVERSATION_ID, "conv")); + assertEquals(StateGraph.END, new StepProgressDispatcher(goals).apply(state)); + org.mockito.Mockito.verify(goals).suspendRuntime(42L, "PLAN_ABORTED"); + } + @Test void replanPhase_routesToPlanGeneration() { String next = dispatcher.apply(state("plan_replan", 0, List.of("a", "b"))); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/DelegatedStepContractTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/DelegatedStepContractTest.java index d2ddbfdc..1fb99cfe 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/DelegatedStepContractTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/DelegatedStepContractTest.java @@ -31,6 +31,19 @@ class DelegatedStepContractTest { MateClawStateKeys.AGENT_ID, "10")))); } + @Test void preservesRuntimeWorkerIdentityForSummaryAndAcceptance() throws Exception { + var adapter = mock(vip.mate.goal.service.GoalDecisionAdapter.class); + when(adapter.enabled()).thenReturn(true); node.setGoalDecisionAdapter(adapter); + var out = run("{\"status\":\"COMPLETED\",\"result\":\"3个百分点\",\"evidence\":\"98%-95%=3\"}", List.of("95%")); + String completed = out.get(PlanStateKeys.COMPLETED_RESULTS).toString(); + assertTrue(completed.contains("assignedAgentId")); + assertTrue(completed.contains("20")); + assertTrue(completed.contains("worker")); + assertTrue(completed.contains("COMPLETED")); + assertTrue(completed.contains("3个百分点")); + assertFalse(completed.contains("PRIVATE_ORIGINAL_GOAL")); + } + @Test void passesBoundedDependencyEvidenceWithoutFullContext() throws Exception { run("{\"status\":\"COMPLETED\",\"result\":\"3个百分点\",\"evidence\":\"98%-95%=3个百分点\"}",List.of("步骤1:95%")); var task = ArgumentCaptor.forClass(String.class); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java index a2c793db..2a26569f 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationAutoGoalTest.java @@ -51,6 +51,19 @@ class PlanGenerationAutoGoalTest { return new PlanStateAccessor(new OverAllState(vals)); } + @Test + void enabledGoalUsesOriginalDeliveryInsteadOfRelaxedPlanSteps() { + when(goalService.create(any(), any())).thenReturn(new GoalEntity()); + var adapter = mock(vip.mate.goal.service.GoalDecisionAdapter.class); + when(adapter.enabled()).thenReturn(true); + var node = node(); node.setGoalDecisionAdapter(adapter); + node.maybeAutoCreateGoal(accessor(true, "计算实际良率,不得猜分母"), + List.of("说明缺分母无法计算也算完成", "输出缺输入说明")); + var cap = ArgumentCaptor.forClass(GoalCreateRequest.class); + verify(goalService).create(cap.capture(), any()); + assertEquals(List.of("计算实际良率,不得猜分母"), cap.getValue().getCriteria().stream().map(c -> c.text()).toList()); + } + @Test void multiStepPlan_createsGoal_seededWithStepCriteria() { GoalEntity created = new GoalEntity(); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java index cd847eb9..07f94db8 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -71,6 +71,33 @@ class GoalPersistenceIntegrationTest { @Autowired private PlatformTransactionManager transactionManager; @Autowired private ApplicationEventPublisher events; + @Autowired private vip.mate.decision.config.DecisionProperties decisions; + + @Test + void runtimeStopAndOutcomeCommitTogetherAndRollbackTogether() { + var previous = decisions.getMode(); + decisions.setMode(vip.mate.decision.api.DecisionMode.ACTIVE); + try { + GoalEntity committed = goalService.create(req("runtime-stop-commit", "calculate yield"), "alice"); + org.junit.jupiter.api.Assertions.assertTrue(goalService.suspendRuntime(committed.getId(), "PLAN_ABORTED")); + assertEquals(GoalStatus.PAUSED, goalService.getById(committed.getId()).getStatus()); + assertEquals("PAUSED", jdbc.queryForObject(""" + SELECT o.actual_value FROM mate_decision_outcome o JOIN mate_decision_record r + ON o.decision_id=r.id WHERE r.subject_id=? AND r.phase='PLAN_ABORTED' + """, String.class, committed.getId())); + GoalEntity rolled = goalService.create(req("runtime-stop-rollback", "calculate yield"), "alice"); + new TransactionTemplate(transactionManager).executeWithoutResult(tx -> { + org.junit.jupiter.api.Assertions.assertTrue(goalService.suspendRuntime(rolled.getId(), "PLAN_ABORTED")); + tx.setRollbackOnly(); + }); + assertEquals(GoalStatus.ACTIVE, goalService.getById(rolled.getId()).getStatus()); + assertEquals(0, jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_decision_outcome o JOIN mate_decision_record r + ON o.decision_id=r.id WHERE r.subject_id=? AND r.phase='PLAN_ABORTED' + """, Integer.class, rolled.getId())); + } finally { decisions.setMode(previous); } + } + private GoalCreateRequest req(String convId, String title) { GoalCreateRequest r = new GoalCreateRequest(); r.setConversationId(convId); 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 index 0892e61d..8dd63668 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java @@ -95,6 +95,23 @@ class GoalEvaluationServiceTest { when(chatModel.call(any(Prompt.class))).thenReturn(response); } + @Test + void enabledOrdinaryGoalHasBoundedReasoningHeadroomWhileOffAndManagedKeepLegacyLimit() { + stubChatResponse("{\"criterionVerdicts\":[],\"summary\":\"pending\"}"); + var decisions = new vip.mate.decision.config.DecisionProperties(); svc.setDecisionProperties(decisions); + svc.evaluate(goalWithCriteria(), List.of(), "answer"); + decisions.setMode(vip.mate.decision.api.DecisionMode.OFF); + svc.evaluate(goalWithCriteria(), List.of(), "answer"); + decisions.setMode(vip.mate.decision.api.DecisionMode.SHADOW); + var managed = goalWithCriteria(); managed.setJsonAcceptanceRequired(true); + svc.evaluate(managed, List.of(), "answer"); + var prompts = org.mockito.ArgumentCaptor.forClass(Prompt.class); + verify(chatModel, org.mockito.Mockito.times(3)).call(prompts.capture()); + assertEquals(4096, prompts.getAllValues().get(0).getOptions().getMaxTokens()); + assertEquals(2000, prompts.getAllValues().get(1).getOptions().getMaxTokens()); + assertEquals(2000, prompts.getAllValues().get(2).getOptions().getMaxTokens()); + } + @Test void blankEvidenceVerdictCannotCompleteOrInflateProgress() { stubChatResponse(""" diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index 1b2f4aab..7d6f140d 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -477,6 +477,46 @@ class GoalServiceTest { // ==================== state transitions ==================== + @Test + void managedGoalKeepsItsExistingLeaseAndApprovalLifecycle() { + var goal = verifiedPersistentGoal(GoalStatus.ACTIVE); goal.setJsonAcceptanceRequired(true); + when(goalMapper.selectById(1L)).thenReturn(goal); + var adapter = mock(GoalDecisionAdapter.class); service.setDecisionAdapter(adapter); + assertFalse(service.suspendRuntime(1L, "EVALUATION_UNAVAILABLE")); + assertFalse(service.suspendRuntime(1L, "PLAN_ABORTED")); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + verifyNoInteractions(adapter); + } + + @Test + void offRuntimeBlockerRetainsLegacyState() { + when(goalMapper.selectById(1L)).thenReturn(verifiedPersistentGoal(GoalStatus.ACTIVE)); + var decisions = mock(vip.mate.decision.core.DecisionService.class); + when(decisions.decide(any())).thenReturn(new vip.mate.decision.api.DecisionTicket(null, + vip.mate.decision.api.DecisionMode.OFF, new vip.mate.decision.api.DecisionValue.Choice("DISABLED"))); + service.setDecisionAdapter(new GoalDecisionAdapter(mock(GoalFollowupService.class), decisions)); + assertFalse(service.suspendRuntime(1L, "PLAN_ABORTED")); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + verifyNoInteractions(eventMapper); + } + + @Test + void runtimeBlockerPausesAndRecordsItsActualOutcome() { + var active = verifiedPersistentGoal(GoalStatus.ACTIVE); + var paused = statusFlipped(active, GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(active, active, paused); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + var decisions = mock(vip.mate.decision.core.DecisionService.class); + var ticket = new vip.mate.decision.api.DecisionTicket("stop", vip.mate.decision.api.DecisionMode.SHADOW, + new vip.mate.decision.api.DecisionValue.Choice("DISABLED")); + when(decisions.decide(any())).thenReturn(ticket); + service.setDecisionAdapter(new GoalDecisionAdapter(mock(GoalFollowupService.class), decisions)); + assertTrue(service.suspendRuntime(1L, "PLAN_ABORTED")); + verify(decisions).recordOutcome(eq(ticket), eq(vip.mate.decision.api.DecisionOutcome.OBSERVED), + eq(new vip.mate.decision.api.DecisionValue.Choice("PAUSED"))); + verify(goalMapper).update(any(), any(LambdaUpdateWrapper.class)); + } + @Test void pause_flipsActiveToPaused_andWritesEvent() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index 907514c2..d411e4f8 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -76,6 +76,36 @@ class TeamDispatchServiceTest { return t; } + @Test + void structuredBlockedResultFailsWithoutRetryOrReleasingDependents() { + TeamTaskEntity running = task(71L, MEMBER_A); + running.setStatus(TeamTaskStatus.IN_PROGRESS); + running.setRequireApproval(true); + when(taskService.getTask(71L)).thenReturn(running); + var adapter = mock(WorkerDecisionAdapter.class); + when(adapter.enabled()).thenReturn(true); + var ticket = new DecisionTicket("blocked", DecisionMode.SHADOW, new DecisionValue.BooleanValue(false)); + when(adapter.judge(any(), anyString(), anyString())) + .thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false)); + service.setDecisionAdapter(adapter); + String reply = "{\"status\":\"BLOCKED\",\"result\":\"\",\"evidence\":\"Missing total count\"}"; + service.settleOutcome(running, reply); + verify(taskService).failTask(eq(71L), startsWith("BLOCKED:"), eq(ticket), any()); + verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any()); + verify(taskService, never()).requeueUnusableResult(any(), anyString()); + verify(adapter).judge(running, "BLOCKED", reply); + } + + @Test + void enabledWorkerEnvelopeRequiresStructuredCompletionEvenWithoutTools() { + var adapter = mock(WorkerDecisionAdapter.class); + when(adapter.enabled()).thenReturn(true); + service.setDecisionAdapter(adapter); + String prompt = service.buildDispatchContent(task(72L, MEMBER_A)); + assertTrue(prompt.contains("COMPLETED|BLOCKED|FAILED")); + assertTrue(prompt.contains("evidence")); + } + // ==================== sweep arbitration ==================== @Test @@ -152,7 +182,7 @@ class TeamDispatchServiceTest { when(taskService.getTask(1L)).thenReturn(running); when(adapter.judge(running, "VALID", "analysis finished")) .thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false)); - service.settleOutcome(running, "analysis finished"); + service.settleOutcome(running, "{\"status\":\"COMPLETED\",\"result\":\"analysis finished\",\"evidence\":\"measured\"}"); verify(taskService).failTask(1L, "worker result did not satisfy task requirements", ticket, snapshot); verify(taskService, never()).requeueUnusableResult(any(), anyString(), any(), any()); verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any()); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/WorkerDecisionTransactionTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/WorkerDecisionTransactionTest.java index b58b71c7..a5544b4b 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/WorkerDecisionTransactionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/WorkerDecisionTransactionTest.java @@ -138,12 +138,12 @@ class WorkerDecisionTransactionTest { assertNull(tasks.getTask(1L).getMetadata()); } - @Test void offAndShadowPreserveLegacySettlementAcrossGuardsAndObservationFailures() { + @Test void offPreservesLegacySettlementAcrossGuardsAndObservationFailures() { rejectProvider = true; for (int fixture = 0; fixture < 13; fixture++) { properties.setMode(DecisionMode.OFF); List expected = settleFixture(fixture, false); - for (DecisionMode mode : List.of(DecisionMode.OFF, DecisionMode.SHADOW)) { + for (DecisionMode mode : List.of(DecisionMode.OFF)) { for (boolean failingStore : List.of(false, true)) { properties.setMode(mode); if (failingStore) doThrow(new IllegalStateException("unavailable")).when(records).insert(any()); @@ -201,12 +201,33 @@ class WorkerDecisionTransactionTest { jdbc.queryForList("SELECT event_type FROM mate_team_task_event ORDER BY create_time,id", String.class)); } + private static String structured(String result) { + return JSONUtil.toJsonStr(java.util.Map.of("status", "COMPLETED", "result", result, "evidence", "measured")); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = DecisionMode.class, names = {"SHADOW", "ACTIVE"}) + void structuredBlockNeverCompletesOrEntersReview(DecisionMode mode) throws Exception { + properties.setMode(mode); + jdbc.update("UPDATE mate_team_task SET require_approval=TRUE WHERE id=1"); + var observed = new CountDownLatch(1); + doAnswer(call -> { call.callRealMethod(); observed.countDown(); return null; }) + .when(records).outcome(any(), any(), any()); + dispatch(mock(AgentService.class)).settleOutcome(tasks.getTask(1L), + "{\"status\":\"BLOCKED\",\"result\":\"\",\"evidence\":\"missing total\"}"); + assertTrue(observed.await(5, TimeUnit.SECONDS)); + assertEquals(TeamTaskStatus.FAILED, tasks.getTask(1L).getStatus()); + assertTrue(tasks.getTask(1L).getReason().contains("missing total")); + assertEquals("FAILED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class)); + assertEquals(0, providerCalls.get()); + } + @Test void healthyShadowActuallyEvaluatesAndObservesCommittedBaseline() throws Exception { properties.setMode(DecisionMode.SHADOW); rejectProvider = true; var observed = new CountDownLatch(1); doAnswer(call -> { call.callRealMethod(); observed.countDown(); return null; }) .when(records).outcome(any(), any(), any()); - dispatch(mock(AgentService.class)).settleOutcome(tasks.getTask(1L), "analysis done"); + dispatch(mock(AgentService.class)).settleOutcome(tasks.getTask(1L), structured("analysis done")); assertTrue(observed.await(5, TimeUnit.SECONDS)); assertEquals(1, providerCalls.get()); assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus()); @@ -229,7 +250,7 @@ class WorkerDecisionTransactionTest { @Test void activeSemanticRejectionCommitsFailureWithoutRetry() { rejectProvider = true; var dispatch = dispatch(mock(AgentService.class)); - dispatch.settleOutcome(tasks.getTask(1L), "analysis done"); + dispatch.settleOutcome(tasks.getTask(1L), structured("analysis done")); assertEquals(TeamTaskStatus.FAILED, tasks.getTask(1L).getStatus()); assertEquals(1, tasks.getTask(1L).getDispatchCount()); assertEquals("FAILED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class)); @@ -241,7 +262,7 @@ class WorkerDecisionTransactionTest { var agent = mock(AgentService.class); when(agent.chatWithUsage(eq(3L), anyString(), anyString())).thenAnswer(call -> { if (reassigned) jdbc.update("UPDATE mate_team_task SET owner_agent_id=4,dispatch_count=2,conversation_id='new-run' WHERE id=1"); - return AgentService.ChatResult.contentOnly("[report](/api/v1/files/generated/12345678-abcd-1234-abcd-123456789abc)"); + return AgentService.ChatResult.contentOnly(structured("[report](/api/v1/files/generated/12345678-abcd-1234-abcd-123456789abc)")); }); dispatch(agent).runTask(2L, tasks.getTask(1L)); assertEquals(reassigned ? TeamTaskStatus.IN_PROGRESS : TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus()); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java index 8165aad3..c320caad 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -89,6 +89,32 @@ class GoalManagementToolTest { assertTrue(result.contains("title is required")); } + @Test + void offRetainsExplicitChecklistAlongsideExitText() { + var settings = new vip.mate.decision.config.DecisionProperties(); + settings.setMode(vip.mate.decision.api.DecisionMode.OFF); tool.setDecisionProperties(settings); + when(goalService.create(any(), eq("alice"))).thenReturn(goal(GoalStatus.ACTIVE)); + tool.setGoal("summary", null, "deliver summary", 2, true, java.util.List.of("original item"), + ctxWith("conv-1", 10L, "alice")); + var request = org.mockito.ArgumentCaptor.forClass(GoalCreateRequest.class); + verify(goalService).create(request.capture(), eq("alice")); + org.junit.jupiter.api.Assertions.assertEquals("original item", request.getValue().getCriteria().getFirst().text()); + } + + @Test + void explicitExitCriteriaWinsOverGeneratedSetupChecklist() { + tool.setDecisionProperties(new vip.mate.decision.config.DecisionProperties()); + when(goalService.create(any(), eq("alice"))).thenReturn(goal(GoalStatus.ACTIVE)); + tool.setGoal("summary", "wait for line then deliver summary", "Output a summary containing the supplied line", + 2, true, java.util.List.of("Create goal", "Wait for input", "Output summary"), + ctxWith("conv-1", 10L, "alice")); + var request = org.mockito.ArgumentCaptor.forClass(GoalCreateRequest.class); + verify(goalService).create(request.capture(), eq("alice")); + org.junit.jupiter.api.Assertions.assertEquals(1, request.getValue().getCriteria().size()); + org.junit.jupiter.api.Assertions.assertEquals("Output a summary containing the supplied line", + request.getValue().getCriteria().getFirst().text()); + } + @Test void setGoal_happyPath_returnsGoalId() { when(goalService.create(any(GoalCreateRequest.class), eq("alice")))