fix(goal): real evaluator, retry refactor, hardened node + extra edges

This commit is contained in:
matevip 2026-05-21 22:27:00 +08:00
parent 495a5c126d
commit 9e93c52d9a
16 changed files with 1149 additions and 202 deletions

View File

@ -546,7 +546,25 @@ public class AgentGraphBuilder {
Map.of(
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
// DIRECT_ANSWER_NODE handles trivial requests that bypass the
// multi-step plan. For active goals, the direct answer is still
// a turn without this edge, turns_used / score / completion
// would never tick on plan-execute conversations whose every
// reply happened to be simple enough to short-circuit through
// the direct path. Mirror PLAN_SUMMARY_NODE's gate so non-goal
// turns still go straight to END (no goal node invocation).
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
boolean hasGoal = a.hasActiveGoal();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END));
return graph.compile(CompileConfig.builder()
.recursionLimit(frameworkRecursionLimit())

View File

@ -11,6 +11,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import vip.mate.llm.chatmodel.ReasoningContentCache;
import reactor.core.Disposable;
@ -1206,6 +1207,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns.
cacheReasoningContent(fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1235,6 +1240,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
// Cache reasoning_content for MiMo-style providers that require it on
// subsequent turns. The cache replays real values instead of empty strings.
cacheReasoningContent(fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1268,6 +1277,24 @@ public class NodeStreamingChatHelper {
return builder.build();
}
/**
* Store reasoning content in the cache for cross-turn replay.
* Only caches when there are tool calls (MiMo requires reasoning_content
* specifically on assistant messages with tool_calls).
*/
private static void cacheReasoningContent(String fullThinking,
List<AssistantMessage.ToolCall> toolCalls) {
if (fullThinking == null || fullThinking.isBlank()) return;
if (toolCalls == null || toolCalls.isEmpty()) return;
List<String> ids = toolCalls.stream()
.map(AssistantMessage.ToolCall::id)
.filter(id -> id != null && !id.isEmpty())
.toList();
if (!ids.isEmpty()) {
ReasoningContentCache.store(ids, fullThinking);
}
}
/**
* Record token / cache usage to the optional metrics aggregator.
* Called only from successful assembly paths ({@link #assembleResult}

View File

@ -137,47 +137,92 @@ public class GoalEvaluationNode implements NodeAction {
recent = recent.subList(recent.size() - max, recent.size());
}
GoalEvaluationResult result = evaluationService.evaluate(goal, recent, terminal);
// Evaluator + persistence wrapped together: the just-emitted final
// answer is the user-visible thing and must NOT be lost just because
// a provider timeout or DB hiccup happens on the way to the
// bookkeeping write. On any failure we mark the run as evaluated
// (so the conditional edge above won't loop us back) and route to
// the normal terminal path the user still sees their answer; the
// goal stays in whatever state it was before this turn.
GoalEvaluationResult result;
GoalEntity refreshed;
try {
result = evaluationService.evaluate(goal, recent, terminal);
// Pre-eval agent_llm count snapshot the bookkeeping helper folds
// it into the per-goal counter so future turns see growing usage.
int agentLlmDelta = accessor.llmCallCount();
int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
// Pre-eval agent_llm count snapshot the bookkeeping helper
// folds it into the per-goal counter so future turns see
// growing usage.
int agentLlmDelta = accessor.llmCallCount();
int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
GoalEntity refreshed = goalService.getById(goal.getId());
// Decision branches.
if (result.completed() || result.score() >= 0.95) {
goalService.markCompleted(refreshed.getId(), result);
refreshed = goalService.getById(goal.getId());
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] evaluator/persist failed for goal={} — skipping this pass: {}",
goal.getId(), t.toString());
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluationResult(GoalEvaluationResult.fallback("node_exception").toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score()))))
.build();
}
if (goalService.isBudgetExhausted(refreshed)) {
String reason = goalService.exhaustionReason(refreshed);
goalService.markExhausted(refreshed.getId(), reason);
// 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
// and abort the streamed answer the user already sees.
try {
if (result.completed() || result.score() >= 0.95) {
goalService.markCompleted(refreshed.getId(), result);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score()))))
.build();
}
if (goalService.isBudgetExhausted(refreshed)) {
String reason = goalService.exhaustionReason(refreshed);
goalService.markExhausted(refreshed.getId(), reason);
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_exhausted", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"turnsUsed", refreshed.getTurnsUsed(),
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
"reason", reason))))
.build();
}
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
refreshed.getId(), t.toString());
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_exhausted", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"turnsUsed", refreshed.getTurnsUsed(),
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
"reason", reason))))
.build();
}
Optional<String> followup = followupService.maybeBuildFollowup(refreshed, result);
Optional<String> followup;
try {
followup = followupService.maybeBuildFollowup(refreshed, result);
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}",
refreshed.getId(), t.toString());
followup = Optional.empty();
}
if (followup.isPresent()) {
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
try {
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] recordFollowupInjected failed — emitting followup anyway: {}",
t.toString());
// Continue: the in-memory state-machine path still works
// even if the audit row could not be written.
}
MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalFollowupInjected(true)

View File

@ -21,6 +21,7 @@ import vip.mate.goal.model.GoalEventEntity;
import vip.mate.goal.model.GoalUpdateRequest;
import vip.mate.goal.service.GoalService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import java.util.List;
import java.util.Map;
@ -53,6 +54,22 @@ public class GoalController {
public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) {
String username = currentUsername(auth);
requireOwner(req.getConversationId(), username);
// Derive agentId/workspaceId from the conversation itself so the
// caller cannot attribute the goal to an unrelated agent/workspace
// they don't own. The request fields for these two are intentionally
// ignored audit, memory sync, listing, and future policy hooks all
// assume the goal mirrors its conversation's identity.
ConversationEntity conv = conversationService.findByConversationId(req.getConversationId());
if (conv == null) {
throw new MateClawException("err.goal.conversation_not_found", 404,
"Conversation not found: " + req.getConversationId());
}
if (conv.getAgentId() == null) {
throw new MateClawException("err.goal.conversation_has_no_agent", 409,
"Conversation " + req.getConversationId() + " has no bound agent");
}
req.setAgentId(conv.getAgentId());
req.setWorkspaceId(conv.getWorkspaceId() != null ? conv.getWorkspaceId() : 1L);
GoalEntity g = goalService.create(req, username);
return R.ok(g);
}

View File

@ -1,43 +1,95 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.llm.chatmodel.ProviderChatModelFactory;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import java.util.ArrayList;
import java.util.List;
/**
* Evaluates whether the agent's latest reply satisfies the goal's exit
* criteria. PR2 ships a deterministic "always continue" stub plus a
* passthrough completion gate; PR5 wires this through to a real LLM call.
* Evaluates whether the assistant's latest reply satisfies a goal's exit
* criteria. Drives the persistent-goal completion path (the
* "auto-followup until score hits 1.0" loop), so it sits on the hot path
* of every chat turn that has an active goal.
*
* <p>The split lets PR2 unblock the graph topology + dispatcher work
* without committing to a particular evaluator model. The stub is
* safe-by-default: it never marks a goal completed and never charges
* eval_llm_calls.
* <p>Returns a deterministic {@link GoalEvaluationResult#fallback fallback}
* when the LLM call is unavailable, errors out, or returns un-parseable
* JSON the {@code GoalEvaluationNode} treats fallback as "skip
* bookkeeping deltas, no event log, stay safe". This degrades cleanly
* when the evaluator provider is misconfigured or transiently down.
*
* <p>Model selection:
* <ol>
* <li>If {@code mateclaw.goal.evaluator-model} names an enabled model,
* use it.</li>
* <li>Otherwise fall back to {@link ModelConfigService#getDefaultModel()}
* convenient for dev, but operators are encouraged to pin a cheap
* evaluator-only model in production since this fires on every turn.
* </li>
* </ol>
*
* <p>Prompt is short and JSON-only: the evaluator returns one object with
* {@code score} (0.01.0 fraction of criteria satisfied), {@code gap}
* (plain-text description of what's missing), and {@code completed} (bool).
*/
@Slf4j
@Service
public class GoalEvaluationService {
private final GoalProperties properties;
private static final int MAX_OUTPUT_TOKENS = 400;
private static final int MAX_CONVERSATION_CHARS = 6_000;
private static final int MAX_TERMINAL_ANSWER_CHARS = 4_000;
/** Skip-retry template — the goal node has its own try/catch, no need to double-retry. */
private static final RetryTemplate ONESHOT = RetryTemplate.builder().maxAttempts(1).build();
public GoalEvaluationService(GoalProperties properties) {
private final GoalProperties properties;
private final ModelConfigService modelConfigService;
private final ProviderChatModelFactory chatModelFactory;
private final ObjectMapper objectMapper;
public GoalEvaluationService(GoalProperties properties,
ModelConfigService modelConfigService,
ProviderChatModelFactory chatModelFactory,
ObjectMapper objectMapper) {
this.properties = properties;
this.modelConfigService = modelConfigService;
this.chatModelFactory = chatModelFactory;
this.objectMapper = objectMapper;
}
/**
* Evaluate one turn's terminal answer against the goal's exit criteria.
* Evaluate one terminal answer against the goal's exit criteria.
*
* <p>Returns {@link GoalEvaluationResult#fallback(String)} when the
* evaluator is unavailable so the calling node can skip the bookkeeping
* delta path. PR5 swaps this implementation with a real LLM call;
* until then we never block the user on evaluator latency.
* <p>Returns a {@link GoalEvaluationResult} carrying the score, gap
* description, decision, model id, and elapsed latency. The
* {@code llmCallsConsumed} field is 1 on success (one evaluator
* call) and 0 on fallback paths so the per-goal LLM-call budget
* stays accurate.
*
* @param goal the active goal under evaluation; never {@code null}
* @param recentMessages most-recent N messages from the parent conversation
* for context; the node already trims by
* {@link GoalProperties#getEvaluatorContextMessages()}
* @param terminalAnswer the assistant's just-emitted final answer text
*/
public GoalEvaluationResult evaluate(GoalEntity goal,
List<?> recentMessages,
List<? extends Message> recentMessages,
String terminalAnswer) {
if (goal == null) {
return GoalEvaluationResult.fallback("no_goal");
@ -45,21 +97,172 @@ public class GoalEvaluationService {
if (terminalAnswer == null || terminalAnswer.isBlank()) {
return GoalEvaluationResult.fallback("empty_answer");
}
// PR2 placeholder: deterministic continue with a passthrough gap.
// Real evaluator lands in PR5 see RFC 48 §3.9.
String model = properties.getEvaluatorModel();
if (model == null || model.isBlank()) {
model = "stub";
ModelConfigEntity model = resolveEvaluatorModel();
if (model == null) {
log.warn("[GoalEvaluation] no evaluator model available (configured={}, default lookup empty)",
properties.getEvaluatorModel());
return GoalEvaluationResult.fallback("no_model");
}
log.debug("[GoalEvaluation] stub evaluate goal={} answerChars={} -> decision=continue",
goal.getId(), terminalAnswer.length());
return new GoalEvaluationResult(
0.0,
"(stub evaluator — wired in PR5)",
GoalEvaluationResult.DECISION_CONTINUE,
false,
model,
0,
0L);
long start = System.currentTimeMillis();
try {
ChatModel chatModel = chatModelFactory.buildFor(model, ONESHOT);
String prompt = buildUserPrompt(goal, recentMessages, terminalAnswer);
List<Message> messages = new ArrayList<>(2);
messages.add(new SystemMessage(SYSTEM_PROMPT));
messages.add(new UserMessage(prompt));
ChatOptions options = ChatOptions.builder()
.temperature(0.1)
.maxTokens(MAX_OUTPUT_TOKENS)
.build();
ChatResponse response = chatModel.call(new Prompt(messages, options));
long elapsed = System.currentTimeMillis() - start;
String body = extractText(response);
if (body == null || body.isBlank()) {
log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName());
return GoalEvaluationResult.fallback("empty_response");
}
return parseJson(body, model.getModelName(), elapsed);
} catch (Throwable t) {
long elapsed = System.currentTimeMillis() - start;
log.warn("[GoalEvaluation] evaluator call failed after {}ms: {}", elapsed, t.toString());
return GoalEvaluationResult.fallback("call_failed");
}
}
// ==================== Internals ====================
private ModelConfigEntity resolveEvaluatorModel() {
String name = properties.getEvaluatorModel();
if (name != null && !name.isBlank()) {
// resolveModel returns the default model when the named one
// can't be found, which is exactly the desired "graceful
// degradation" semantics for a misconfigured evaluator id.
return modelConfigService.resolveModel(name);
}
return modelConfigService.getDefaultModel();
}
private static final String SYSTEM_PROMPT =
"You are a goal-completion evaluator. You judge whether an AI "
+ "assistant's latest reply satisfies a user's stated goal. "
+ "Output exactly ONE JSON object with the keys score, gap, "
+ "completed. No markdown, no commentary, no extra prose.";
private String buildUserPrompt(GoalEntity goal,
List<? extends Message> recentMessages,
String terminalAnswer) {
StringBuilder sb = new StringBuilder(2048);
sb.append("Goal title: ").append(safe(goal.getTitle())).append('\n');
if (goal.getDescription() != null && !goal.getDescription().isBlank()) {
sb.append("Goal description: ").append(safe(goal.getDescription())).append('\n');
}
if (goal.getExitCriteria() != null && !goal.getExitCriteria().isBlank()) {
sb.append("Exit criteria:\n").append(safe(goal.getExitCriteria())).append('\n');
}
sb.append('\n');
if (recentMessages != null && !recentMessages.isEmpty()) {
sb.append("Recent conversation (oldest first):\n");
String convo = serializeMessages(recentMessages);
if (convo.length() > MAX_CONVERSATION_CHARS) {
convo = convo.substring(convo.length() - MAX_CONVERSATION_CHARS);
}
sb.append(convo).append('\n');
}
String answer = terminalAnswer;
if (answer.length() > MAX_TERMINAL_ANSWER_CHARS) {
answer = answer.substring(0, MAX_TERMINAL_ANSWER_CHARS) + "\n... [truncated]";
}
sb.append("\nAssistant's latest final answer to evaluate:\n").append(answer).append('\n');
sb.append('\n')
.append("Return exactly:\n")
.append("{\n")
.append(" \"score\": <number 0.0 to 1.0 — fraction of exit criteria satisfied>,\n")
.append(" \"gap\": \"<short plain-text description of what's still missing; empty when score=1.0>\",\n")
.append(" \"completed\": <true if every exit criterion is fully satisfied, else false>\n")
.append("}");
return sb.toString();
}
private String serializeMessages(List<? extends Message> messages) {
StringBuilder sb = new StringBuilder();
for (Message m : messages) {
String role = m.getMessageType() != null ? m.getMessageType().getValue() : "msg";
String text = m.getText();
if (text == null) text = "";
sb.append(role).append(": ").append(text.strip()).append('\n');
}
return sb.toString();
}
private String extractText(ChatResponse response) {
if (response == null || response.getResult() == null
|| response.getResult().getOutput() == null) {
return null;
}
return response.getResult().getOutput().getText();
}
/**
* Parse the evaluator's JSON output. The model may wrap the object in
* ```json fences despite the system prompt telling it not to, so we
* locate the first {@code {...}} substring and parse that. Anything
* else (non-numeric score, missing fields, malformed JSON) downgrades
* to a fallback result rather than throwing.
*/
private GoalEvaluationResult parseJson(String body, String modelName, long latencyMs) {
String trimmed = body.strip();
int braceStart = trimmed.indexOf('{');
int braceEnd = trimmed.lastIndexOf('}');
if (braceStart < 0 || braceEnd <= braceStart) {
log.warn("[GoalEvaluation] no JSON object in evaluator output: {}",
trimmed.length() > 200 ? trimmed.substring(0, 200) + "..." : trimmed);
return GoalEvaluationResult.fallback("parse_no_object");
}
String json = trimmed.substring(braceStart, braceEnd + 1);
try {
JsonNode node = objectMapper.readTree(json);
JsonNode scoreNode = node.get("score");
if (scoreNode == null || !scoreNode.isNumber()) {
return GoalEvaluationResult.fallback("parse_missing_score");
}
double score = clamp01(scoreNode.asDouble());
String gap = node.hasNonNull("gap") ? node.get("gap").asText("") : "";
boolean completed = node.hasNonNull("completed") && node.get("completed").asBoolean(false);
// Belt-and-braces: a perfect score implies completion; let the
// node's >= 0.95 threshold handle the gray zone.
if (score >= 1.0 - 1e-9) completed = true;
String decision = completed
? GoalEvaluationResult.DECISION_COMPLETED
: GoalEvaluationResult.DECISION_CONTINUE;
return new GoalEvaluationResult(
score, gap, decision, completed,
modelName != null ? modelName : "", 1, latencyMs);
} catch (Exception e) {
log.warn("[GoalEvaluation] JSON parse failed: {} — body={}",
e.getMessage(),
json.length() > 200 ? json.substring(0, 200) + "..." : json);
return GoalEvaluationResult.fallback("parse_failed");
}
}
private static double clamp01(double v) {
if (Double.isNaN(v)) return 0.0;
if (v < 0.0) return 0.0;
if (v > 1.0) return 1.0;
return v;
}
private static String safe(String s) {
return s == null ? "" : s;
}
}

View File

@ -28,7 +28,6 @@ import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.IntSupplier;
/**
* Default implementation. Concurrency safety relies on:
@ -183,45 +182,49 @@ public class GoalServiceImpl implements GoalService {
@Override
@Transactional
public GoalEntity update(Long id, GoalUpdateRequest req, String username) {
GoalEntity g = getById(id);
ensureNotTerminal(g, "update");
// Pre-validate constant fields once; the actual not-terminal check
// happens inside the builder against the fresh entity so a status
// flip between this method's entry and a CAS retry is honoured.
if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget");
if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget");
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g);
boolean changed = false;
if (req.getTitle() != null && !req.getTitle().isBlank()) {
w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true;
}
if (req.getDescription() != null) {
w.set(GoalEntity::getDescription, req.getDescription()); changed = true;
}
if (req.getExitCriteria() != null) {
w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true;
}
if (req.getSuccessCheckPrompt() != null) {
w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true;
}
if (req.getTurnBudget() != null) {
validateBudget(req.getTurnBudget(), "turnBudget");
w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
}
if (req.getLlmCallBudget() != null) {
validateBudget(req.getLlmCallBudget(), "llmCallBudget");
w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true;
}
if (req.getAutoFollowupEnabled() != null) {
w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true;
}
if (req.getFollowupCooldownSeconds() != null) {
w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds());
changed = true;
}
if (!changed) {
return g;
}
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "update");
recordAudit("goal.updated", g, Map.of("by", username));
return goalMapper.selectById(id);
GoalEntity updated = retryOptimistic(id, "update", fresh -> {
ensureNotTerminal(fresh, "update");
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh);
boolean changed = false;
if (req.getTitle() != null && !req.getTitle().isBlank()) {
w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true;
}
if (req.getDescription() != null) {
w.set(GoalEntity::getDescription, req.getDescription()); changed = true;
}
if (req.getExitCriteria() != null) {
w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true;
}
if (req.getSuccessCheckPrompt() != null) {
w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true;
}
if (req.getTurnBudget() != null) {
w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
}
if (req.getLlmCallBudget() != null) {
w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true;
}
if (req.getAutoFollowupEnabled() != null) {
w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true;
}
if (req.getFollowupCooldownSeconds() != null) {
w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds());
changed = true;
}
if (!changed) {
return null; // idempotent no-op
}
bumpVersionAndTime(w);
return w;
});
recordAudit("goal.updated", updated, Map.of("by", username));
return updated;
}
@Override
@ -251,31 +254,33 @@ public class GoalServiceImpl implements GoalService {
@Override
@Transactional
public GoalEntity abandon(Long id, String username) {
GoalEntity g = getById(id);
ensureNotTerminal(g, "abandon");
// Allows abandon from both ACTIVE and PAUSED.
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getStatus, GoalStatus.ABANDONED);
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "abandon");
GoalEntity updated = retryOptimistic(id, "abandon", fresh -> {
ensureNotTerminal(fresh, "abandon");
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.ABANDONED);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username));
recordAudit("goal.abandoned", g, Map.of("by", username));
return goalMapper.selectById(id);
recordAudit("goal.abandoned", updated, Map.of("by", username));
return updated;
}
@Override
@Transactional
public GoalEntity markCompleted(Long id, GoalEvaluationResult result) {
GoalEntity g = getById(id);
if (g.getStatus().isTerminal()) return g; // idempotent
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getStatus, GoalStatus.COMPLETED);
if (result != null) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "markCompleted");
GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> {
if (fresh.getStatus().isTerminal()) return null; // idempotent
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.COMPLETED);
if (result != null) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
bumpVersionAndTime(w);
return w;
});
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("finalScore", result != null ? result.score() : null);
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
@ -283,7 +288,7 @@ public class GoalServiceImpl implements GoalService {
writeEvent(id, GoalEventType.COMPLETED, null, detail);
recordAudit("goal.completed", g, detail);
// RFC 48 PR5 forward to long-term memory. Best-effort: a failing
// Forward to long-term memory on completion. Best-effort: a failing
// memory pipeline must not roll back the DB transition.
if (memoryManager != null) {
try {
@ -300,18 +305,19 @@ public class GoalServiceImpl implements GoalService {
}
}
return goalMapper.selectById(id);
return g;
}
@Override
@Transactional
public GoalEntity markExhausted(Long id, String reason) {
GoalEntity g = getById(id);
if (g.getStatus().isTerminal()) return g;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "markExhausted");
GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> {
if (fresh.getStatus().isTerminal()) return null;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
bumpVersionAndTime(w);
return w;
});
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("reason", reason != null ? reason : "unknown");
detail.put("turnsUsed", g.getTurnsUsed());
@ -319,7 +325,7 @@ public class GoalServiceImpl implements GoalService {
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
writeEvent(id, GoalEventType.EXHAUSTED, null, detail);
recordAudit("goal.exhausted", g, detail);
return goalMapper.selectById(id);
return g;
}
// ==================== Evaluation bookkeeping ====================
@ -328,23 +334,30 @@ public class GoalServiceImpl implements GoalService {
@Transactional
public void recordEvaluation(Long id, GoalEvaluationResult result,
int agentLlmCallsDelta, int evalLlmCallsDelta) {
GoalEntity g = getById(id);
if (g.getStatus().isTerminal()) return; // ignore late evaluations
// Cheap pre-check so terminal goals also skip the event write the
// in-loop guard below still protects against a status flip during a
// contended retry, but it would also issue the event log entry that
// a no-op skip should not produce.
GoalEntity initial = goalMapper.selectById(id);
if (initial == null || initial.getStatus().isTerminal()) return;
int agentDelta = Math.max(0, agentLlmCallsDelta);
int evalDelta = Math.max(0, evalLlmCallsDelta);
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.setSql("turns_used = turns_used + 1")
.setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta)
.setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta)
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
if (result != null) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "recordEvaluation");
retryOptimistic(id, "recordEvaluation", fresh -> {
if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.setSql("turns_used = turns_used + 1")
.setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta)
.setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta)
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
if (result != null) {
w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap());
}
bumpVersionAndTime(w);
return w;
});
Map<String, Object> detail = new LinkedHashMap<>();
if (result != null) {
@ -379,12 +392,18 @@ public class GoalServiceImpl implements GoalService {
@Override
@Transactional
public void recordFollowupInjected(Long id, String prompt) {
GoalEntity g = getById(id);
if (g.getStatus().isTerminal()) return;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getLastFollowupAt, LocalDateTime.now());
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "recordFollowupInjected");
// Mirror recordEvaluation's pre-check so a late followup on a
// terminal goal does not produce an event-log entry.
GoalEntity initial = goalMapper.selectById(id);
if (initial == null || initial.getStatus().isTerminal()) return;
GoalEntity g = retryOptimistic(id, "recordFollowupInjected", fresh -> {
if (fresh.getStatus().isTerminal()) return null;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getLastFollowupAt, LocalDateTime.now());
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.FOLLOWUP_INJECTED, null, Map.of(
"prompt", prompt != null ? prompt : "",
"turnsUsed", g.getTurnsUsed()));
@ -396,19 +415,22 @@ public class GoalServiceImpl implements GoalService {
if (criterion == null || criterion.isBlank()) {
throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty");
}
GoalEntity g = getById(id);
ensureNotTerminal(g, "appendCriterion");
String existing = g.getExitCriteria() != null ? g.getExitCriteria() : "";
String merged = existing.isEmpty() ? criterion.trim()
: existing + "\n+ " + criterion.trim();
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getExitCriteria, merged);
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "appendCriterion");
String trimmed = criterion.trim();
// Merge against the freshly refetched criteria so a concurrent
// addCriterion never silently overwrites a sibling's append.
GoalEntity g = retryOptimistic(id, "appendCriterion", fresh -> {
ensureNotTerminal(fresh, "appendCriterion");
String existing = fresh.getExitCriteria() != null ? fresh.getExitCriteria() : "";
String merged = existing.isEmpty() ? trimmed : existing + "\n+ " + trimmed;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getExitCriteria, merged);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of(
"criterion", criterion.trim(),
"criterion", trimmed,
"by", username));
return goalMapper.selectById(id);
return g;
}
// ==================== Internals ====================
@ -463,10 +485,48 @@ public class GoalServiceImpl implements GoalService {
.set(GoalEntity::getUpdateTime, LocalDateTime.now());
}
private void retryOptimistic(IntSupplier update, String op) {
/**
* Builder that takes the just-refetched goal entity and either returns a
* fully prepared {@link LambdaUpdateWrapper} (with version pinned to the
* fresh row) or returns {@code null} to signal "no-op, treat as success"
* for idempotent skips (e.g. terminal-state guards).
*/
@FunctionalInterface
private interface UpdateBuilder {
LambdaUpdateWrapper<GoalEntity> build(GoalEntity fresh);
}
/**
* Refetch + rebuild + CAS loop. The builder is invoked on each attempt
* against a freshly loaded entity so the {@code WHERE version=?} clause
* always matches the current row version. Returns the post-update entity
* (or the unchanged fresh entity when the builder signals a no-op).
*
* <p>This replaces the earlier single-shot wrapper capture which could
* not recover from the very first CAS miss once {@code oldVersion}
* went stale, all subsequent retries with the same wrapper were
* doomed. Refetching per attempt is the correct shape for optimistic
* locking: read, build delta against the read, CAS on the read's
* version.
*/
private GoalEntity retryOptimistic(Long id, String op, UpdateBuilder builder) {
for (int i = 0; i < OPTIMISTIC_LOCK_MAX_RETRIES; i++) {
int rows = update.getAsInt();
if (rows > 0) return;
GoalEntity fresh = goalMapper.selectById(id);
if (fresh == null) {
throw new MateClawException("err.goal.not_found", 404,
"Goal not found: " + id);
}
LambdaUpdateWrapper<GoalEntity> w = builder.build(fresh);
if (w == null) {
// Builder declined to issue a write (idempotent skip). Treat
// as success callers like markCompleted hit this when the
// goal already moved to a terminal state via another path.
return fresh;
}
int rows = goalMapper.update(null, w);
if (rows > 0) {
return goalMapper.selectById(id);
}
log.debug("[GoalService] Optimistic lock miss on {} (attempt {}/{})",
op, i + 1, OPTIMISTIC_LOCK_MAX_RETRIES);
}
@ -475,21 +535,23 @@ public class GoalServiceImpl implements GoalService {
+ OPTIMISTIC_LOCK_MAX_RETRIES + " retries");
}
private GoalEntity flipStatus(Long id, GoalStatus from, GoalStatus to,
String eventType, String auditAction, String username) {
GoalEntity g = getById(id);
if (g.getStatus() != from) {
throw new MateClawException("err.goal.bad_transition", 409,
"Cannot transition " + g.getStatus().getValue() + " -> " + to.getValue());
}
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
.set(GoalEntity::getStatus, to);
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), to.getValue());
GoalEntity g = retryOptimistic(id, to.getValue(), fresh -> {
if (fresh.getStatus() != from) {
throw new MateClawException("err.goal.bad_transition", 409,
"Cannot transition " + fresh.getStatus().getValue() + " -> " + to.getValue());
}
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, to);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, eventType, null, Map.of("by", username,
"from", from.getValue(), "to", to.getValue()));
recordAudit(auditAction, g, Map.of("by", username));
return goalMapper.selectById(id);
return g;
}
private void writeEvent(Long goalId, String type, Long messageId, Map<String, Object> detail) {

View File

@ -137,12 +137,17 @@ final class OpenAiRequestRewriter {
if (next != null && !next.isEmpty()) {
injected = next;
} else {
injected = policy.emptyFallback;
if (injected == null && policy.warnOnMissingReal) {
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
+ "but relay has no value for assistant message at index {}; "
+ "leaving null so provider returns explicit error.",
providerIdOrUnknown(provider), i);
// For cross-turn messages, try the reasoning content cache
// (real values from prior responses) before falling back to empty.
injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx);
if (injected == null) {
injected = policy.emptyFallback;
if (injected == null && policy.warnOnMissingReal) {
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
+ "but relay has no value for assistant message at index {}; "
+ "leaving null so provider returns explicit error.",
providerIdOrUnknown(provider), i);
}
}
}
if (injected == null && msg.reasoningContent() == null) {
@ -225,10 +230,11 @@ final class OpenAiRequestRewriter {
* OpenAI-compatible gateway) might still require the patch.
*/
private enum FallbackPolicy {
DEEPSEEK(" ", false, true, true),
KIMI (" ", false, false, false),
OPENAI (" ", false, false, false),
DEFAULT (" ", false, false, false);
DEEPSEEK (" ", false, true, true),
KIMI (" ", false, false, false),
OPENAI (" ", false, false, false),
XIAOMI_MIMO (" ", false, true, true),
DEFAULT (" ", false, false, false);
final String emptyFallback;
final boolean warnOnMissingReal;
@ -253,6 +259,7 @@ final class OpenAiRequestRewriter {
case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI;
case "xiaomi-mimo" -> XIAOMI_MIMO;
default -> DEFAULT;
};
}
@ -306,6 +313,22 @@ final class OpenAiRequestRewriter {
return family.isThinking();
}
/**
* Look up cached reasoning content for cross-turn assistant messages.
* Returns the cached value, or {@code null} if no cache hit (caller falls
* back to the policy's empty fallback).
*/
private static String resolveCrossTurnReasoning(
OpenAiApi.ChatCompletionMessage msg, boolean isCrossTurn) {
if (!isCrossTurn) return null;
if (msg.toolCalls() == null || msg.toolCalls().isEmpty()) return null;
List<String> ids = msg.toolCalls().stream()
.map(tc -> tc.id())
.filter(id -> id != null && !id.isEmpty())
.toList();
return ReasoningContentCache.get(ids);
}
// ==================== reasoning_effort sanitizing ====================
/**

View File

@ -0,0 +1,112 @@
package vip.mate.llm.chatmodel;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* Static singleton cache for MiMo-style {@code reasoning_content} replay.
*
* <p>MiMo (and similar providers) require {@code reasoning_content} on assistant
* messages that carry {@code tool_calls}. When the conversation spans multiple
* turns, the cache replays the <em>real</em> reasoning content from prior
* responses instead of injecting empty strings, preserving model context.
*
* <h2>Key design</h2>
* <ul>
* <li>Key: sorted, concatenated tool_call IDs from the assistant message.
* Tool call IDs are unique per response, so this key naturally
* disambiguates across turns.</li>
* <li>TTL: 24 hours (configurable). Entries older than TTL are lazily evicted
* on access and periodically during {@link #store}.</li>
* <li>Max entries: 10,000. Oldest entries evicted when exceeded.</li>
* </ul>
*
* <h2>Usage</h2>
* <ol>
* <li><b>Store</b>: after a streaming response completes, call
* {@link #store} with the tool_call IDs and reasoning content.</li>
* <li><b>Retrieve</b>: during request patching, call {@link #get} for
* cross-turn assistant messages to fill in cached reasoning.</li>
* </ol>
*
* @author MateClaw Team
*/
public final class ReasoningContentCache {
private static final long DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000L; // 24 hours
private static final int DEFAULT_MAX_ENTRIES = 10_000;
private static final long EVICT_INTERVAL_MS = 5 * 60 * 1000L; // 5 minutes
private static final ConcurrentHashMap<String, Entry> MAP = new ConcurrentHashMap<>();
private static volatile long lastEvictMs = System.currentTimeMillis();
private ReasoningContentCache() {}
/**
* Cache reasoning content for a set of tool_call IDs.
*
* @param toolCallIds tool call IDs from the assistant message (must not be null/empty)
* @param reasoningContent the real reasoning content to cache (must not be blank)
*/
public static void store(List<String> toolCallIds, String reasoningContent) {
if (toolCallIds == null || toolCallIds.isEmpty()) return;
if (reasoningContent == null || reasoningContent.isBlank()) return;
String key = makeKey(toolCallIds);
MAP.put(key, new Entry(reasoningContent, System.currentTimeMillis()));
maybeEvict();
}
/**
* Retrieve cached reasoning content for the given tool_call IDs.
*
* @return cached reasoning content, or {@code null} if not found or expired
*/
public static String get(List<String> toolCallIds) {
if (toolCallIds == null || toolCallIds.isEmpty()) return null;
String key = makeKey(toolCallIds);
Entry entry = MAP.get(key);
if (entry == null) return null;
if (System.currentTimeMillis() - entry.storedAtMs > DEFAULT_MAX_AGE_MS) {
MAP.remove(key);
return null;
}
return entry.reasoningContent;
}
/** Clear all cached entries. */
public static void clear() {
MAP.clear();
}
/** Current cache size (for diagnostics). */
public static int size() {
return MAP.size();
}
private static String makeKey(List<String> toolCallIds) {
return String.join("|", toolCallIds.stream().sorted().toList());
}
private static void maybeEvict() {
long now = System.currentTimeMillis();
if (now - lastEvictMs < EVICT_INTERVAL_MS) return;
lastEvictMs = now;
// Remove expired entries
MAP.entrySet().removeIf(e -> now - e.getValue().storedAtMs > DEFAULT_MAX_AGE_MS);
// Remove oldest if over limit
if (MAP.size() > DEFAULT_MAX_ENTRIES) {
MAP.entrySet().stream()
.sorted((a, b) -> Long.compare(a.getValue().storedAtMs, b.getValue().storedAtMs))
.limit(MAP.size() - DEFAULT_MAX_ENTRIES)
.forEach(e -> MAP.remove(e.getKey()));
}
}
private record Entry(String reasoningContent, long storedAtMs) {}
}

View File

@ -58,6 +58,15 @@ public enum ModelFamily {
*/
DEEPSEEK_V4_REASONING(false, false, true, false, false, true),
/**
* Xiaomi MiMo thinking 模型MiMo-VL-*mimo-* 系列
* <p>
* MiMo thinking 模式与 DeepSeek 类似响应返回 {@code reasoning_content}
* 后续多轮请求必须将 {@code reasoning_content} 传回否则 API 返回 400 错误
* 约束保留 max_tokens不支持 reasoning_efforttemperature/topP 用配置值
*/
MIMO_THINKING(false, false, false, false, false, true),
/**
* 通用 thinking 模型名称含 "thinking" "reasoner" 但不匹配上述族
* qwen3-235b-a22b-thinking-2507
@ -160,6 +169,11 @@ public enum ModelFamily {
return DEEPSEEK_REASONER;
}
// Xiaomi MiMo thinking mimo-* / MiMo-VL-* 系列
if (normalized.startsWith("mimo")) {
return MIMO_THINKING;
}
// 通用 thinking 名称含 thinking / reasoner 关键词
if (normalized.contains("thinking") || normalized.contains("reasoner")) {
return GENERIC_THINKING;

View File

@ -1296,6 +1296,25 @@ public class ConversationService {
return username.equals(conv.getUsername()) || SYSTEM_USER.equals(conv.getUsername());
}
/**
* Look up a conversation by its string (UUID-style) id, returning the
* full entity or {@code null} when not found. Read-only does not
* create or mutate.
*
* <p>Callers that need to derive {@code agentId} / {@code workspaceId}
* from a conversation (so the request cannot lie about either) should
* use this rather than re-running the {@code LambdaQueryWrapper}
* boilerplate inline.
*/
public ConversationEntity findByConversationId(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return null;
}
return conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
}
/**
* Get the persisted stream status for a conversation.
*

View File

@ -14,6 +14,7 @@ import vip.mate.goal.model.GoalStatus;
import vip.mate.goal.model.GoalUpdateRequest;
import vip.mate.goal.service.GoalService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import java.util.Map;
@ -72,11 +73,21 @@ class GoalControllerTest {
return r;
}
private ConversationEntity conv(String convId, Long agentId, Long workspaceId) {
ConversationEntity c = new ConversationEntity();
c.setConversationId(convId);
c.setUsername("alice");
c.setAgentId(agentId);
c.setWorkspaceId(workspaceId);
return c;
}
// ==================== create ====================
@Test
void create_succeeds_whenOwner() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
when(conversationService.findByConversationId("conv-1")).thenReturn(conv("conv-1", 10L, 1L));
when(goalService.create(any(), eq("alice")))
.thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
R<GoalEntity> result = controller.create(req("conv-1"), auth);
@ -84,6 +95,46 @@ class GoalControllerTest {
assertEquals(1L, result.getData().getId());
}
@Test
void create_overridesAgentAndWorkspace_fromConversation() {
// Request claims agentId=99 / workspaceId=77 the controller must
// ignore those and use the conversation's own bindings instead.
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
when(conversationService.findByConversationId("conv-1")).thenReturn(conv("conv-1", 42L, 7L));
when(goalService.create(any(), eq("alice")))
.thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
GoalCreateRequest r = req("conv-1");
r.setAgentId(99L);
r.setWorkspaceId(77L);
controller.create(r, auth);
org.mockito.ArgumentCaptor<GoalCreateRequest> captor =
org.mockito.ArgumentCaptor.forClass(GoalCreateRequest.class);
verify(goalService).create(captor.capture(), eq("alice"));
assertEquals(42L, captor.getValue().getAgentId());
assertEquals(7L, captor.getValue().getWorkspaceId());
}
@Test
void create_returns404_whenConversationMissing() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
when(conversationService.findByConversationId("conv-1")).thenReturn(null);
MateClawException ex = assertThrows(MateClawException.class,
() -> controller.create(req("conv-1"), auth));
assertEquals(404, ex.getCode());
verify(goalService, never()).create(any(), anyString());
}
@Test
void create_returns409_whenConversationHasNoAgent() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
when(conversationService.findByConversationId("conv-1"))
.thenReturn(conv("conv-1", null, 1L));
MateClawException ex = assertThrows(MateClawException.class,
() -> controller.create(req("conv-1"), auth));
assertEquals(409, ex.getCode());
verify(goalService, never()).create(any(), anyString());
}
@Test
void create_returns403_whenNotOwner() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false);

View File

@ -1,10 +1,25 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.retry.support.RetryTemplate;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalStatus;
import vip.mate.llm.chatmodel.ProviderChatModelFactory;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import java.util.List;
@ -12,63 +27,220 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Covers the PR2 stub evaluator: deterministic continue + safe fallback
* paths. The LLM-backed evaluator lands in PR5; until then any "real"
* evaluation always returns continue without charging eval_llm_calls.
* Covers the LLM-backed evaluator: prompt construction is exercised
* via the integration with a mocked {@link ChatModel}, JSON parsing
* corners (markdown fences, missing fields, malformed JSON), and the
* fallback degradation paths that protect the chat turn when the
* evaluator provider is unavailable.
*/
@ExtendWith(MockitoExtension.class)
class GoalEvaluationServiceTest {
private final GoalProperties props = new GoalProperties();
private final GoalEvaluationService svc = new GoalEvaluationService(props);
@Mock private ModelConfigService modelConfigService;
@Mock private ProviderChatModelFactory chatModelFactory;
@Mock private ChatModel chatModel;
private GoalProperties props;
private GoalEvaluationService svc;
@BeforeEach
void setUp() {
props = new GoalProperties();
svc = new GoalEvaluationService(props, modelConfigService, chatModelFactory, new ObjectMapper());
}
private GoalEntity goal() {
GoalEntity g = new GoalEntity();
g.setId(1L);
g.setTitle("ship the blog");
g.setDescription("deploy to fly.io");
g.setExitCriteria("hello world page accessible");
g.setStatus(GoalStatus.ACTIVE);
return g;
}
private ModelConfigEntity model(String name) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider("dashscope");
m.setModelName(name);
return m;
}
private void stubChatResponse(String body) {
when(modelConfigService.getDefaultModel()).thenReturn(model("qwen-turbo"));
when(chatModelFactory.buildFor(any(ModelConfigEntity.class), any(RetryTemplate.class)))
.thenReturn(chatModel);
ChatResponse response = new ChatResponse(List.of(
new Generation(new AssistantMessage(body))));
when(chatModel.call(any(Prompt.class))).thenReturn(response);
}
// ==================== Pre-flight guards ====================
@Test
void nullGoal_returnsFallback() {
void nullGoal_returnsFallback_withoutTouchingProviders() {
GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertFalse(r.completed());
assertEquals(0, r.llmCallsConsumed());
verify(chatModelFactory, never()).buildFor(any(), any());
}
@Test
void emptyAnswer_returnsFallback() {
void emptyAnswer_returnsFallback_withoutTouchingProviders() {
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
verify(chatModelFactory, never()).buildFor(any(), any());
}
@Test
void normalCall_returnsContinue() {
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text");
assertNotNull(r);
void noModelAvailable_returnsFallback() {
// Both lookup paths return null no default, no override.
when(modelConfigService.getDefaultModel()).thenReturn(null);
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "any answer");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertTrue(r.gap().contains("no_model"));
verify(chatModelFactory, never()).buildFor(any(), any());
}
// ==================== Happy paths ====================
@Test
void continueDecision_whenScoreBelowOne() {
stubChatResponse("{\"score\": 0.6, \"gap\": \"DNS not configured yet\", \"completed\": false}");
GoalEvaluationResult r = svc.evaluate(goal(),
List.of(new UserMessage("status?")),
"DNS configured, still need TLS");
assertEquals(GoalEvaluationResult.DECISION_CONTINUE, r.decision());
assertFalse(r.completed());
assertEquals(0, r.llmCallsConsumed(),
"PR2 stub must not charge eval_llm_calls — that path lands in PR5");
}
@Test
void evaluatorModel_defaultsToStub_whenPropertyBlank() {
props.setEvaluatorModel("");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text");
assertEquals("stub", r.evaluatorModel());
}
@Test
void evaluatorModel_carriesPropertyValue_whenConfigured() {
props.setEvaluatorModel("qwen-turbo");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text");
assertEquals(0.6, r.score(), 1e-9);
assertEquals("DNS not configured yet", r.gap());
assertEquals(1, r.llmCallsConsumed());
assertEquals("qwen-turbo", r.evaluatorModel());
}
@Test
void completedDecision_whenJsonSaysCompleted() {
stubChatResponse("{\"score\": 0.95, \"gap\": \"\", \"completed\": true}");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "all green");
assertEquals(GoalEvaluationResult.DECISION_COMPLETED, r.decision());
assertTrue(r.completed());
}
@Test
void scoreOfOne_implicitlyCompletes_evenWhenJsonSaysFalse() {
stubChatResponse("{\"score\": 1.0, \"gap\": \"\", \"completed\": false}");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "perfect answer");
assertTrue(r.completed(), "score=1.0 must imply completed regardless of the bool field");
assertEquals(GoalEvaluationResult.DECISION_COMPLETED, r.decision());
}
@Test
void score_clampedTo01_whenModelReturnsOutOfRange() {
stubChatResponse("{\"score\": 1.7, \"gap\": \"\", \"completed\": true}");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "answer");
assertEquals(1.0, r.score(), 1e-9);
}
@Test
void negativeScore_clampedToZero() {
stubChatResponse("{\"score\": -0.2, \"gap\": \"x\", \"completed\": false}");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "answer");
assertEquals(0.0, r.score(), 1e-9);
}
// ==================== Parser tolerance ====================
@Test
void parsesEvenWhenWrappedInMarkdownFences() {
// Lenient stub: parser tolerance shouldn't depend on a specific code path.
stubChatResponse("```json\n"
+ "{\"score\": 0.4, \"gap\": \"still need TLS\", \"completed\": false}\n"
+ "```");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "DNS set up");
assertEquals(GoalEvaluationResult.DECISION_CONTINUE, r.decision());
assertEquals(0.4, r.score(), 1e-9);
assertEquals("still need TLS", r.gap());
}
@Test
void parseFails_whenNoJsonObjectInOutput() {
stubChatResponse("I think it's about 60% done.");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertEquals(0, r.llmCallsConsumed());
}
@Test
void parseFails_whenScoreFieldMissing() {
stubChatResponse("{\"gap\": \"missing\", \"completed\": false}");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertTrue(r.gap().contains("parse_missing_score"));
}
@Test
void parseFails_whenJsonMalformed() {
// Closing brace present but interior is invalid exercises the
// ObjectMapper.readTree exception path rather than the cheaper
// "no object found" pre-check.
stubChatResponse("{\"score\": 0.5, \"gap\": }");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertTrue(r.gap().contains("parse_failed"));
}
// ==================== Failure modes ====================
@Test
void emptyResponseFromModel_returnsFallback() {
stubChatResponse(" ");
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertTrue(r.gap().contains("empty_response"));
}
@Test
void modelCallThrows_returnsFallback_andDoesNotPropagate() {
when(modelConfigService.getDefaultModel()).thenReturn(model("qwen-turbo"));
when(chatModelFactory.buildFor(any(), any())).thenReturn(chatModel);
when(chatModel.call(any(Prompt.class))).thenThrow(new RuntimeException("provider down"));
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertTrue(r.gap().contains("call_failed"));
assertEquals(0, r.llmCallsConsumed());
}
// ==================== Model resolution ====================
@Test
void usesNamedEvaluatorModel_whenPropertySet() {
props.setEvaluatorModel("qwen-evaluator-small");
ModelConfigEntity named = model("qwen-evaluator-small");
when(modelConfigService.resolveModel("qwen-evaluator-small")).thenReturn(named);
when(chatModelFactory.buildFor(eq(named), any())).thenReturn(chatModel);
ChatResponse response = new ChatResponse(List.of(
new Generation(new AssistantMessage(
"{\"score\":0.5,\"gap\":\"\",\"completed\":false}"))));
when(chatModel.call(any(Prompt.class))).thenReturn(response);
// Default lookup is never consulted when an override is configured.
lenient().when(modelConfigService.getDefaultModel()).thenReturn(null);
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "x");
assertEquals("qwen-evaluator-small", r.evaluatorModel());
assertNotNull(r);
}
// ==================== Fallback factory ====================
@Test
void fallback_doesNotChargeLlmCalls() {
GoalEvaluationResult r = GoalEvaluationResult.fallback("evaluator_unavailable");

View File

@ -331,6 +331,39 @@ class GoalServiceTest {
verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class));
}
/**
* Regression: after the first CAS miss the retry loop must refetch the
* entity so the rebuilt wrapper carries the current version. Previously
* the wrapper was captured once with version=oldVersion, so once stale
* it could never succeed even when contention cleared.
*/
@Test
void update_succeedsOnSecondAttempt_afterRefetchPicksUpFreshVersion() {
GoalEntity v0 = persisted(1L, GoalStatus.ACTIVE);
v0.setVersion(0);
GoalEntity v1 = persisted(1L, GoalStatus.ACTIVE);
v1.setVersion(1);
GoalEntity v2 = persisted(1L, GoalStatus.ACTIVE);
v2.setVersion(2);
// First refetch returns v0 (stale CAS will miss). Second refetch
// returns v1 (fresh CAS will succeed). Third call (post-update
// selectById) returns the final v2 state for the return value.
when(goalMapper.selectById(1L)).thenReturn(v0, v1, v2);
// First update misses (rows=0), second update succeeds (rows=1).
when(goalMapper.update(any(), any(LambdaUpdateWrapper.class)))
.thenReturn(0).thenReturn(1);
GoalUpdateRequest upd = new GoalUpdateRequest();
upd.setTitle("retry-survives");
GoalEntity out = service.update(1L, upd, "alice");
assertNotNull(out);
// Two update attempts (one miss + one hit) plus three selectById
// calls (two for the loop refetch, one for the post-update return).
verify(goalMapper, times(2)).update(any(), any(LambdaUpdateWrapper.class));
verify(goalMapper, times(3)).selectById(1L);
}
@Test
void findActiveByConversation_returnsNull_forBlankInput() {
assertNull(service.findActiveByConversation(""));

View File

@ -82,11 +82,13 @@ class PatchReasoningContentTest {
@BeforeEach
void clearRelay() {
AssistantThinkingRelay.clearAll();
ReasoningContentCache.clear();
}
@AfterEach
void clearRelayAfter() {
AssistantThinkingRelay.clearAll();
ReasoningContentCache.clear();
}
// ---------- No-relay, no-thinking-mode path ----------
@ -427,4 +429,73 @@ class PatchReasoningContentTest {
assertEquals(" ", out.messages().get(3).reasoningContent(),
"DEEPSEEK plain in-turn assistant gets ' ' as before");
}
// ---------- XIAOMI_MIMO policy + cross-turn cache replay ----------
@Test
@DisplayName("XIAOMI_MIMO cross-turn tool_call: cache hit replays real reasoning_content")
void xiaomiMimoCrossTurn_replaysCachedReasoning() {
// Prior turn produced a tool_call with real thinking; NodeStreamingChatHelper
// stored it in the cache keyed by tool_call_id. On the next turn, the same
// assistant message is replayed as history with reasoning_content=null
// resolveCrossTurnReasoning must fetch the cached value before falling
// back to the policy's empty " ".
ReasoningContentCache.store(List.of("call_1"), "real-prior-thinking");
// Empty relay: no in-turn thinking (current turn hasn't produced one yet).
String token = AssistantThinkingRelay.stash(List.of(""), null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null), // i=1, cross-turn (1 <= 2), tool_call id="call_1"
user("q2") // i=2, lastUserIdx
), token);
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo"));
assertEquals("real-prior-thinking", out.messages().get(1).reasoningContent(),
"XIAOMI_MIMO cross-turn tool_call must replay cached reasoning_content over the ' ' fallback");
}
@Test
@DisplayName("XIAOMI_MIMO cross-turn tool_call: cache miss falls back to ' '")
void xiaomiMimoCrossTurnCacheMiss_fallsBackToSpace() {
// No cache entry for call_1 the multi-turn path must still validate by
// injecting the policy's emptyFallback so MiMo doesn't 400.
String token = AssistantThinkingRelay.stash(List.of(""), null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null), // i=1, cross-turn, no cache entry
user("q2")
), token);
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"XIAOMI_MIMO cross-turn cache miss falls back to ' ' so the request still validates");
}
@Test
@DisplayName("XIAOMI_MIMO plain cross-turn assistant (no tool_calls) also patched via patchNonToolCall=true")
void xiaomiMimoCrossTurnPlainAssistant_patchedWithSpace() {
// XIAOMI_MIMO mirrors DEEPSEEK: patchNonToolCall=true means even plain
// text assistants in prior turns must carry reasoning_content. Cache
// can't help here (no tool_call_ids to key on) fallback is " ".
String token = AssistantThinkingRelay.stash(List.of("", ""), null);
ChatCompletionRequest req = request(List.of(
user("q1"),
new ChatCompletionMessage("plain a1", Role.ASSISTANT), // i=1, cross-turn, no tool_calls
user("q2"),
new ChatCompletionMessage("plain a2", Role.ASSISTANT) // i=3, in-turn, no tool_calls
), token);
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("xiaomi-mimo"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"XIAOMI_MIMO plain prior-turn assistant gets ' ' (patchNonToolCall=true + patchCrossTurn=true)");
assertEquals(" ", out.messages().get(3).reasoningContent(),
"XIAOMI_MIMO plain in-turn assistant gets ' ' (patchNonToolCall=true)");
}
}

View File

@ -0,0 +1,69 @@
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ReasoningContentCacheTest {
@AfterEach
void cleanup() {
ReasoningContentCache.clear();
}
@Test
@DisplayName("Store and retrieve reasoning content by tool_call IDs")
void storeAndGet() {
List<String> ids = List.of("call_1", "call_2");
ReasoningContentCache.store(ids, "thinking content here");
assertEquals("thinking content here", ReasoningContentCache.get(ids));
}
@Test
@DisplayName("Cache key is order-independent (sorted tool_call IDs)")
void orderIndependent() {
ReasoningContentCache.store(List.of("call_b", "call_a"), "content");
assertEquals("content", ReasoningContentCache.get(List.of("call_a", "call_b")));
}
@Test
@DisplayName("Miss returns null")
void cacheMiss() {
assertNull(ReasoningContentCache.get(List.of("nonexistent")));
}
@Test
@DisplayName("Empty/null tool_call IDs are no-ops")
void emptyIds() {
ReasoningContentCache.store(List.of(), "content");
ReasoningContentCache.store(null, "content");
assertEquals(0, ReasoningContentCache.size());
}
@Test
@DisplayName("Blank/null reasoning content is not cached")
void blankContent() {
ReasoningContentCache.store(List.of("call_1"), "");
ReasoningContentCache.store(List.of("call_1"), " ");
ReasoningContentCache.store(List.of("call_1"), null);
assertEquals(0, ReasoningContentCache.size());
}
@Test
@DisplayName("Clear removes all entries")
void clearAll() {
ReasoningContentCache.store(List.of("call_1"), "content1");
ReasoningContentCache.store(List.of("call_2"), "content2");
assertEquals(2, ReasoningContentCache.size());
ReasoningContentCache.clear();
assertEquals(0, ReasoningContentCache.size());
assertNull(ReasoningContentCache.get(List.of("call_1")));
}
}

View File

@ -65,4 +65,15 @@ class ModelFamilyTest {
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(""));
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" "));
}
@Test
@DisplayName("Xiaomi MiMo models → MIMO_THINKING (reasoning_content relay required)")
void mimo_thinking() {
assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("mimo-v2-flash"));
assertEquals(ModelFamily.MIMO_THINKING, ModelFamily.detect("MiMo-VL-7B-RL"));
assertTrue(ModelFamily.MIMO_THINKING.isThinking(),
"Mimo must be flagged as thinking so reasoning_content is patched");
assertFalse(ModelFamily.MIMO_THINKING.supportsReasoningEffort(),
"Mimo does not accept the reasoning_effort parameter");
}
}