fix(decision): enforce worker contracts and bound goal recovery

This commit is contained in:
matevip 2026-09-29 05:26:39 -04:00
parent 0df511f43e
commit 17c87dfa3a
24 changed files with 479 additions and 40 deletions

View File

@ -715,6 +715,7 @@ public class AgentGraphBuilder {
// the team task board instead of the serial delegation pipeline. // the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge); planGenerationNode.setTeamPlanBridge(teamPlanBridge);
planGenerationNode.setRoutingAdapter(routingDecisionAdapter); planGenerationNode.setRoutingAdapter(routingDecisionAdapter);
planGenerationNode.setGoalDecisionAdapter(goalDecisionAdapter);
List<ToolCallback> advertisedCallbacks = toolDisclosureService List<ToolCallback> advertisedCallbacks = toolDisclosureService
.split(toolSet, Set.of(), autoDemotedTools).activeCallbacks(); .split(toolSet, Set.of(), autoDemotedTools).activeCallbacks();
AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks( AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks(
@ -725,6 +726,7 @@ public class AgentGraphBuilder {
// Per-step delegation: route a step assigned to a specialist agent // Per-step delegation: route a step assigned to a specialist agent
// through DelegateAgentTool (null when delegation deps aren't wired). // through DelegateAgentTool (null when delegation deps aren't wired).
stepExecutionNode.setDelegateAgentTool(delegateAgentTool); stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
stepExecutionNode.setGoalDecisionAdapter(goalDecisionAdapter);
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper); PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
planSummaryNode.setGoalService(goalService); planSummaryNode.setGoalService(goalService);
DirectAnswerNode directAnswerNode = new DirectAnswerNode(); DirectAnswerNode directAnswerNode = new DirectAnswerNode();
@ -877,7 +879,7 @@ public class AgentGraphBuilder {
// rebuilt from team tasks — summarize directly. // rebuilt from team tasks — summarize directly.
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE)) PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE, .addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncEdgeAction.edge_async(new StepProgressDispatcher()), AsyncEdgeAction.edge_async(new StepProgressDispatcher(goalService)),
Map.of( Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,

View File

@ -175,6 +175,21 @@ public class GoalEvaluationNode implements NodeAction {
GoalEntity refreshed; GoalEntity refreshed;
try { try {
result = evaluationService.evaluate(goal, recent, terminal); 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. // Bill only the NEW agent LLM calls since the last accounted point.
// The run-to-completion loop evaluates multiple times per graph run // 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(); .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 // Decision branches. Each terminal write is wrapped so a DB hiccup
// (e.g. optimistic-lock conflict exceeding retries, memory sync // (e.g. optimistic-lock conflict exceeding retries, memory sync
// failure on completion) does not propagate into the chat graph // failure on completion) does not propagate into the chat graph

View File

@ -21,12 +21,23 @@ import java.util.List;
* @author MateClaw Team * @author MateClaw Team
*/ */
public class StepProgressDispatcher implements EdgeAction { 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 @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public String apply(OverAllState state) { public String apply(OverAllState state) {
// 审批暂停态或步骤执行失败中止态:直接结束当前图 tick // 审批暂停态或步骤执行失败中止态:直接结束当前图 tick
String currentPhase = state.value(MateClawStateKeys.CURRENT_PHASE, ""); 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)) { if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
return StateGraph.END; return StateGraph.END;
} }

View File

@ -45,25 +45,7 @@ final class DelegatedStepContract {
} }
} }
record Result(boolean completed, String text, String reason) {} static vip.mate.agent.runtime.WorkerResultContract.Result parse(String reply) {
return vip.mate.agent.runtime.WorkerResultContract.parse(reply, 4000);
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();
}
} }
private static Result invalid() { return new Result(false, "", "INVALID_RESULT_CONTRACT"); }
} }

View File

@ -70,6 +70,11 @@ public class PlanGenerationNode implements NodeAction {
/** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */ /** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */
private final GoalService goalService; private final GoalService goalService;
private final GoalProperties goalProperties; 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 /** Optional — advertise delegatable specialist agents to the planner and
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */ * resolve per-step assignments. Null disables per-step delegation (legacy/test). */
private final AgentService agentService; 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 * Auto-derive a goal from a freshly-generated multi-step plan so the
* Plan-Execute path engages the goal subsystem (the planner / step executor * Plan-Execute path engages the goal subsystem (the planner / step executor
* never call {@code setGoal} themselves). The plan steps become the goal's * never call {@code setGoal} themselves). The plan steps become the goal's
* acceptance criteria — the plan IS the decomposition — so the first * acceptance criteria in legacy mode. Enabled decision modes preserve the original
* evaluation skips the bootstrap round and judges those criteria directly. * objective so model-generated steps cannot relax the user's completion requirements.
* *
* <p>Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS * <p>Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS
* run's GoalEvaluationNode picks it up) or {@code null} when not applicable: * 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.length() > AUTO_GOAL_TITLE_MAX
? request.substring(0, AUTO_GOAL_TITLE_MAX) : request); ? request.substring(0, AUTO_GOAL_TITLE_MAX) : request);
req.setDescription(request); req.setDescription(request);
List<GoalCriterion> criteria = steps.stream() boolean preserveObjective = goalDecisionAdapter != null && goalDecisionAdapter.enabled()
.filter(s -> s != null && !s.isBlank()) && !request.isBlank();
.map(s -> new GoalCriterion("", s.strip(), false, "")) List<GoalCriterion> criteria = preserveObjective
.collect(Collectors.toList()); ? 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()) { if (!criteria.isEmpty()) {
req.setCriteria(criteria); req.setCriteria(criteria);
} }
@ -658,6 +667,14 @@ public class PlanGenerationNode implements NodeAction {
+ workingContext)); + workingContext));
} }
if (goalDecisionAdapter != null && goalDecisionAdapter.enabled()) {
promptMessages.add(new SystemMessage("""
保留用户要求的实际交付标准。缺少必要输入时,步骤仍要求原交付物,并明确报告 BLOCKED。
不得自行添加“无法计算则说明原因也算完成”等替代验收条件。
阻塞说明是停止信号,不是用户要求的计算结果;只有用户原本要求解释原因时才可作为交付。
不要把创建目标、等待输入、计划生成当成最终交付。
"""));
}
promptMessages.add(new UserMessage("用户目标:" + goal)); promptMessages.add(new UserMessage("用户目标:" + goal));
// Append JSON schema hint generated by BeanOutputConverter so the LLM // Append JSON schema hint generated by BeanOutputConverter so the LLM

View File

@ -82,6 +82,11 @@ public class StepExecutionNode implements NodeAction {
* specialist agent runs on that agent. Null disables per-step delegation. * specialist agent runs on that agent. Null disables per-step delegation.
*/ */
private DelegateAgentTool delegateAgentTool; 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) { public void setDelegateAgentTool(DelegateAgentTool delegateAgentTool) {
this.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 // Keep the rolling working-context in sync exactly like the local path
// so later steps see this delegated step's result. // so later steps see this delegated step's result.
String prevWorkingContext = accessor.workingContext(); 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() String updatedWorkingContext = prevWorkingContext.isEmpty()
? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep)) ? rebuildWorkingContext(accessor, appendOne(accessor.completedResults(), formattedNewStep))
: appendStepIncremental(prevWorkingContext, formattedNewStep); : appendStepIncremental(prevWorkingContext, formattedNewStep);

View File

@ -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"); }
}

View File

@ -37,6 +37,8 @@ public class DecisionService implements AutoCloseable {
return new ThreadPoolExecutor(threads, threads, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(capacity), return new ThreadPoolExecutor(threads, threads, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(capacity),
Thread.ofPlatform().daemon(true).name(prefix, 0).factory(), new ThreadPoolExecutor.AbortPolicy()); 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) { public DecisionTicket decide(DecisionRequest request) {
var mode = properties.modeFor(request.type()); var mode = properties.modeFor(request.type());
metrics.counter("mate.decision.requests", "type", request.type().name(), "mode", mode.name()).increment(); metrics.counter("mate.decision.requests", "type", request.type().name(), "mode", mode.name()).increment();

View File

@ -33,6 +33,13 @@ public class GoalDecisionAdapter {
this.decisions = decisions; 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 record Selection(DecisionTicket ticket, GoalContinuationDecision decision) {}
public Selection select(GoalEntity goal, GoalEvaluationResult result, LocalDateTime now, public Selection select(GoalEntity goal, GoalEvaluationResult result, LocalDateTime now,

View File

@ -64,10 +64,17 @@ public class GoalEvaluationService implements Evaluator {
/** /**
* Token budget for the evaluator response. Reasoning-mode models consume * Token budget for the evaluator response. Reasoning-mode models consume
* a chunk of this on internal thinking before emitting JSON; 2000 leaves * internal thinking before emitting JSON. Keep the old limit in OFF/managed
* comfort for the reasoning trace plus the small object we need. * flows; ordinary enabled goals need bounded headroom after observed LENGTH exits.
*/ */
private static final int MAX_OUTPUT_TOKENS = 2000; 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_CONVERSATION_CHARS = 6_000;
private static final int MAX_TERMINAL_ANSWER_CHARS = 4_000; private static final int MAX_TERMINAL_ANSWER_CHARS = 4_000;
private static final int MAX_SUCCESS_CHECK_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() ChatOptions options = ChatOptions.builder()
.temperature(0.1) .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(); .build();
ChatResponse response = chatModel.call(new Prompt(messages, options)); ChatResponse response = chatModel.call(new Prompt(messages, options));
@ -154,7 +164,11 @@ public class GoalEvaluationService implements Evaluator {
String body = extractText(response); String body = extractText(response);
if (body == null || body.isBlank()) { 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. // The call was really spent — bill it.
return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed) return GoalEvaluationResult.fallbackAfterCall("empty_response", model.getModelName(), elapsed)
.withEvaluationRevision(evaluationRevision); .withEvaluationRevision(evaluationRevision);

View File

@ -54,6 +54,10 @@ public interface GoalService {
GoalEntity pause(Long id, String username); 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. */ /** Pause a persistent active goal until essential user input or permission is provided. */
GoalEntity waitForInput(Long id, String reason, String username); GoalEntity waitForInput(Long id, String reason, String username);
GoalEntity resume(Long id, String username); GoalEntity resume(Long id, String username);

View File

@ -335,6 +335,37 @@ public class GoalServiceImpl implements GoalService {
// ==================== State machine ==================== // ==================== 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<GoalEntity> update = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.PAUSED)
.set(GoalEntity::getProgressSummary, "Runtime paused: " + reasonCode + "; review and explicitly resume.");
bumpVersionAndTime(update);
return update;
});
Map<String, Object> 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 @Override
@Transactional @Transactional
public GoalEntity pause(Long id, String username) { public GoalEntity pause(Long id, String username) {

View File

@ -330,6 +330,21 @@ public class TeamDispatchService {
return; return;
} }
if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) { 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); boolean attachedGeneratedFile = attachGeneratedFileDeliverable(current, reply, snapshot);
String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile); String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile);
if (invalidReason != null) { 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 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. - 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())); """.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(); return sb.toString();
} }

View File

@ -42,6 +42,13 @@ public class GoalManagementTool {
private final GoalProperties properties; private final GoalProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker; 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 = """ @Tool(description = """
Set a persistent goal for the current conversation. The agent will \ Set a persistent goal for the current conversation. The agent will \
@ -62,7 +69,7 @@ public class GoalManagementTool {
+ "Omit to use the system default.", + "Omit to use the system default.",
required = false) Boolean autoFollowup, required = false) Boolean autoFollowup,
@ToolParam(description = "Optional initial checklist: a list of short, individually verifiable " @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<String> criteria, required = false) java.util.List<String> criteria,
@Nullable ToolContext ctx) { @Nullable ToolContext ctx) {
@ -90,7 +97,15 @@ public class GoalManagementTool {
req.setExitCriteria(exitCriteria); req.setExitCriteria(exitCriteria);
if (turnBudget != null) req.setTurnBudget(turnBudget); if (turnBudget != null) req.setTurnBudget(turnBudget);
if (autoFollowup != null) req.setAutoFollowupEnabled(autoFollowup); 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<vip.mate.goal.model.GoalCriterion> items = new java.util.ArrayList<>(); java.util.List<vip.mate.goal.model.GoalCriterion> items = new java.util.ArrayList<>();
for (String text : criteria) { for (String text : criteria) {
if (text != null && !text.isBlank()) { if (text != null && !text.isBlank()) {

View File

@ -68,6 +68,63 @@ class GoalEvaluationNodeContinuationTest {
// ===== Pure decision helpers ===== // ===== Pure decision helpers =====
@Test
void emptyEvaluationDoesNotRetryPastCombinedBudget() throws Exception {
Fixture f = new Fixture();
var state = f.state(FinishReason.NORMAL.getValue(), 0, 0);
state.<GoalEntity>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 @Test
void hardSkip_coversUserAndNonProgressTerminals_only() { void hardSkip_coversUserAndNonProgressTerminals_only() {
assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.STOPPED.getValue())); assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.STOPPED.getValue()));

View File

@ -28,6 +28,17 @@ class StepProgressDispatcherTest {
return new OverAllState(vals); 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 @Test
void replanPhase_routesToPlanGeneration() { void replanPhase_routesToPlanGeneration() {
String next = dispatcher.apply(state("plan_replan", 0, List.of("a", "b"))); String next = dispatcher.apply(state("plan_replan", 0, List.of("a", "b")));

View File

@ -31,6 +31,19 @@ class DelegatedStepContractTest {
MateClawStateKeys.AGENT_ID, "10")))); 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 { @Test void passesBoundedDependencyEvidenceWithoutFullContext() throws Exception {
run("{\"status\":\"COMPLETED\",\"result\":\"3个百分点\",\"evidence\":\"98%-95%=3个百分点\"}",List.of("步骤1:95%")); run("{\"status\":\"COMPLETED\",\"result\":\"3个百分点\",\"evidence\":\"98%-95%=3个百分点\"}",List.of("步骤1:95%"));
var task = ArgumentCaptor.forClass(String.class); var task = ArgumentCaptor.forClass(String.class);

View File

@ -51,6 +51,19 @@ class PlanGenerationAutoGoalTest {
return new PlanStateAccessor(new OverAllState(vals)); 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 @Test
void multiStepPlan_createsGoal_seededWithStepCriteria() { void multiStepPlan_createsGoal_seededWithStepCriteria() {
GoalEntity created = new GoalEntity(); GoalEntity created = new GoalEntity();

View File

@ -71,6 +71,33 @@ class GoalPersistenceIntegrationTest {
@Autowired private PlatformTransactionManager transactionManager; @Autowired private PlatformTransactionManager transactionManager;
@Autowired private ApplicationEventPublisher events; @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) { private GoalCreateRequest req(String convId, String title) {
GoalCreateRequest r = new GoalCreateRequest(); GoalCreateRequest r = new GoalCreateRequest();
r.setConversationId(convId); r.setConversationId(convId);

View File

@ -95,6 +95,23 @@ class GoalEvaluationServiceTest {
when(chatModel.call(any(Prompt.class))).thenReturn(response); 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 @Test
void blankEvidenceVerdictCannotCompleteOrInflateProgress() { void blankEvidenceVerdictCannotCompleteOrInflateProgress() {
stubChatResponse(""" stubChatResponse("""

View File

@ -477,6 +477,46 @@ class GoalServiceTest {
// ==================== state transitions ==================== // ==================== 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 @Test
void pause_flipsActiveToPaused_andWritesEvent() { void pause_flipsActiveToPaused_andWritesEvent() {
GoalEntity g = persisted(1L, GoalStatus.ACTIVE); GoalEntity g = persisted(1L, GoalStatus.ACTIVE);

View File

@ -76,6 +76,36 @@ class TeamDispatchServiceTest {
return t; 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 ==================== // ==================== sweep arbitration ====================
@Test @Test
@ -152,7 +182,7 @@ class TeamDispatchServiceTest {
when(taskService.getTask(1L)).thenReturn(running); when(taskService.getTask(1L)).thenReturn(running);
when(adapter.judge(running, "VALID", "analysis finished")) when(adapter.judge(running, "VALID", "analysis finished"))
.thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false)); .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).failTask(1L, "worker result did not satisfy task requirements", ticket, snapshot);
verify(taskService, never()).requeueUnusableResult(any(), anyString(), any(), any()); verify(taskService, never()).requeueUnusableResult(any(), anyString(), any(), any());
verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any()); verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any());

View File

@ -138,12 +138,12 @@ class WorkerDecisionTransactionTest {
assertNull(tasks.getTask(1L).getMetadata()); assertNull(tasks.getTask(1L).getMetadata());
} }
@Test void offAndShadowPreserveLegacySettlementAcrossGuardsAndObservationFailures() { @Test void offPreservesLegacySettlementAcrossGuardsAndObservationFailures() {
rejectProvider = true; rejectProvider = true;
for (int fixture = 0; fixture < 13; fixture++) { for (int fixture = 0; fixture < 13; fixture++) {
properties.setMode(DecisionMode.OFF); properties.setMode(DecisionMode.OFF);
List<Object> expected = settleFixture(fixture, false); List<Object> 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)) { for (boolean failingStore : List.of(false, true)) {
properties.setMode(mode); properties.setMode(mode);
if (failingStore) doThrow(new IllegalStateException("unavailable")).when(records).insert(any()); 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)); 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 { @Test void healthyShadowActuallyEvaluatesAndObservesCommittedBaseline() throws Exception {
properties.setMode(DecisionMode.SHADOW); rejectProvider = true; properties.setMode(DecisionMode.SHADOW); rejectProvider = true;
var observed = new CountDownLatch(1); var observed = new CountDownLatch(1);
doAnswer(call -> { call.callRealMethod(); observed.countDown(); return null; }) doAnswer(call -> { call.callRealMethod(); observed.countDown(); return null; })
.when(records).outcome(any(), any(), any()); .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)); assertTrue(observed.await(5, TimeUnit.SECONDS));
assertEquals(1, providerCalls.get()); assertEquals(1, providerCalls.get());
assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus()); assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus());
@ -229,7 +250,7 @@ class WorkerDecisionTransactionTest {
@Test void activeSemanticRejectionCommitsFailureWithoutRetry() { @Test void activeSemanticRejectionCommitsFailureWithoutRetry() {
rejectProvider = true; rejectProvider = true;
var dispatch = dispatch(mock(AgentService.class)); 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(TeamTaskStatus.FAILED, tasks.getTask(1L).getStatus());
assertEquals(1, tasks.getTask(1L).getDispatchCount()); assertEquals(1, tasks.getTask(1L).getDispatchCount());
assertEquals("FAILED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class)); assertEquals("FAILED", jdbc.queryForObject("SELECT actual_value FROM mate_decision_outcome", String.class));
@ -241,7 +262,7 @@ class WorkerDecisionTransactionTest {
var agent = mock(AgentService.class); var agent = mock(AgentService.class);
when(agent.chatWithUsage(eq(3L), anyString(), anyString())).thenAnswer(call -> { 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"); 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)); dispatch(agent).runTask(2L, tasks.getTask(1L));
assertEquals(reassigned ? TeamTaskStatus.IN_PROGRESS : TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus()); assertEquals(reassigned ? TeamTaskStatus.IN_PROGRESS : TeamTaskStatus.COMPLETED, tasks.getTask(1L).getStatus());

View File

@ -89,6 +89,32 @@ class GoalManagementToolTest {
assertTrue(result.contains("title is required")); 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 @Test
void setGoal_happyPath_returnsGoalId() { void setGoal_happyPath_returnsGoalId() {
when(goalService.create(any(GoalCreateRequest.class), eq("alice"))) when(goalService.create(any(GoalCreateRequest.class), eq("alice")))