mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
fix(decision): enforce worker contracts and bound goal recovery
This commit is contained in:
parent
0df511f43e
commit
17c87dfa3a
@ -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<ToolCallback> 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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"); }
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <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:
|
||||
@ -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<GoalCriterion> 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<GoalCriterion> 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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"); }
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<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
|
||||
@Transactional
|
||||
public GoalEntity pause(Long id, String username) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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<String> 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<vip.mate.goal.model.GoalCriterion> items = new java.util.ArrayList<>();
|
||||
for (String text : criteria) {
|
||||
if (text != null && !text.isBlank()) {
|
||||
|
||||
@ -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.<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
|
||||
void hardSkip_coversUserAndNonProgressTerminals_only() {
|
||||
assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.STOPPED.getValue()));
|
||||
|
||||
@ -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")));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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("""
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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<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)) {
|
||||
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());
|
||||
|
||||
@ -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")))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user