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( Map.of(
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END)) 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() return graph.compile(CompileConfig.builder()
.recursionLimit(frameworkRecursionLimit()) .recursionLimit(frameworkRecursionLimit())

View File

@ -11,6 +11,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.chatmodel.AssistantThinkingRelay; import vip.mate.llm.chatmodel.AssistantThinkingRelay;
import vip.mate.llm.chatmodel.ReasoningContentCache;
import reactor.core.Disposable; import reactor.core.Disposable;
@ -1206,6 +1207,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); 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); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1235,6 +1240,10 @@ public class NodeStreamingChatHelper {
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls); 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); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
@ -1268,6 +1277,24 @@ public class NodeStreamingChatHelper {
return builder.build(); 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. * Record token / cache usage to the optional metrics aggregator.
* Called only from successful assembly paths ({@link #assembleResult} * 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()); 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 // Pre-eval agent_llm count snapshot the bookkeeping helper
// it into the per-goal counter so future turns see growing usage. // folds it into the per-goal counter so future turns see
int agentLlmDelta = accessor.llmCallCount(); // growing usage.
int evalLlmDelta = result.llmCallsConsumed(); int agentLlmDelta = accessor.llmCallCount();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta); int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
GoalEntity refreshed = goalService.getById(goal.getId()); refreshed = goalService.getById(goal.getId());
} catch (Throwable t) {
// Decision branches. log.warn("[GoalEvaluationNode] evaluator/persist failed for goal={} — skipping this pass: {}",
if (result.completed() || result.score() >= 0.95) { goal.getId(), t.toString());
goalService.markCompleted(refreshed.getId(), result);
return MateClawStateAccessor.output() return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap()) .goalEvaluationResult(GoalEvaluationResult.fallback("node_exception").toMap())
.goalEvaluatedThisRun(true) .goalEvaluatedThisRun(true)
.events(List.of(goalEvent("goal_completed", Map.of(
"goalId", String.valueOf(refreshed.getId()),
"score", result.score()))))
.build(); .build();
} }
if (goalService.isBudgetExhausted(refreshed)) { // Decision branches. Each terminal write is wrapped so a DB hiccup
String reason = goalService.exhaustionReason(refreshed); // (e.g. optimistic-lock conflict exceeding retries, memory sync
goalService.markExhausted(refreshed.getId(), reason); // 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() return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap()) .goalEvaluationResult(result.toMap())
.goalEvaluatedThisRun(true) .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(); .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()) { 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() MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap()) .goalEvaluationResult(result.toMap())
.goalFollowupInjected(true) .goalFollowupInjected(true)

View File

@ -21,6 +21,7 @@ import vip.mate.goal.model.GoalEventEntity;
import vip.mate.goal.model.GoalUpdateRequest; import vip.mate.goal.model.GoalUpdateRequest;
import vip.mate.goal.service.GoalService; import vip.mate.goal.service.GoalService;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -53,6 +54,22 @@ public class GoalController {
public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) { public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) {
String username = currentUsername(auth); String username = currentUsername(auth);
requireOwner(req.getConversationId(), username); 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); GoalEntity g = goalService.create(req, username);
return R.ok(g); return R.ok(g);
} }

View File

@ -1,43 +1,95 @@
package vip.mate.goal.service; package vip.mate.goal.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; 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 org.springframework.stereotype.Service;
import vip.mate.goal.config.GoalProperties; import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult; 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; import java.util.List;
/** /**
* Evaluates whether the agent's latest reply satisfies the goal's exit * Evaluates whether the assistant's latest reply satisfies a goal's exit
* criteria. PR2 ships a deterministic "always continue" stub plus a * criteria. Drives the persistent-goal completion path (the
* passthrough completion gate; PR5 wires this through to a real LLM call. * "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 * <p>Returns a deterministic {@link GoalEvaluationResult#fallback fallback}
* without committing to a particular evaluator model. The stub is * when the LLM call is unavailable, errors out, or returns un-parseable
* safe-by-default: it never marks a goal completed and never charges * JSON the {@code GoalEvaluationNode} treats fallback as "skip
* eval_llm_calls. * 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 @Slf4j
@Service @Service
public class GoalEvaluationService { 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.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 * <p>Returns a {@link GoalEvaluationResult} carrying the score, gap
* evaluator is unavailable so the calling node can skip the bookkeeping * description, decision, model id, and elapsed latency. The
* delta path. PR5 swaps this implementation with a real LLM call; * {@code llmCallsConsumed} field is 1 on success (one evaluator
* until then we never block the user on evaluator latency. * 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, public GoalEvaluationResult evaluate(GoalEntity goal,
List<?> recentMessages, List<? extends Message> recentMessages,
String terminalAnswer) { String terminalAnswer) {
if (goal == null) { if (goal == null) {
return GoalEvaluationResult.fallback("no_goal"); return GoalEvaluationResult.fallback("no_goal");
@ -45,21 +97,172 @@ public class GoalEvaluationService {
if (terminalAnswer == null || terminalAnswer.isBlank()) { if (terminalAnswer == null || terminalAnswer.isBlank()) {
return GoalEvaluationResult.fallback("empty_answer"); return GoalEvaluationResult.fallback("empty_answer");
} }
// PR2 placeholder: deterministic continue with a passthrough gap.
// Real evaluator lands in PR5 see RFC 48 §3.9. ModelConfigEntity model = resolveEvaluatorModel();
String model = properties.getEvaluatorModel(); if (model == null) {
if (model == null || model.isBlank()) { log.warn("[GoalEvaluation] no evaluator model available (configured={}, default lookup empty)",
model = "stub"; properties.getEvaluatorModel());
return GoalEvaluationResult.fallback("no_model");
} }
log.debug("[GoalEvaluation] stub evaluate goal={} answerChars={} -> decision=continue",
goal.getId(), terminalAnswer.length()); long start = System.currentTimeMillis();
return new GoalEvaluationResult( try {
0.0, ChatModel chatModel = chatModelFactory.buildFor(model, ONESHOT);
"(stub evaluator — wired in PR5)", String prompt = buildUserPrompt(goal, recentMessages, terminalAnswer);
GoalEvaluationResult.DECISION_CONTINUE,
false, List<Message> messages = new ArrayList<>(2);
model, messages.add(new SystemMessage(SYSTEM_PROMPT));
0, messages.add(new UserMessage(prompt));
0L);
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.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.IntSupplier;
/** /**
* Default implementation. Concurrency safety relies on: * Default implementation. Concurrency safety relies on:
@ -183,45 +182,49 @@ public class GoalServiceImpl implements GoalService {
@Override @Override
@Transactional @Transactional
public GoalEntity update(Long id, GoalUpdateRequest req, String username) { public GoalEntity update(Long id, GoalUpdateRequest req, String username) {
GoalEntity g = getById(id); // Pre-validate constant fields once; the actual not-terminal check
ensureNotTerminal(g, "update"); // 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); GoalEntity updated = retryOptimistic(id, "update", fresh -> {
boolean changed = false; ensureNotTerminal(fresh, "update");
if (req.getTitle() != null && !req.getTitle().isBlank()) { LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh);
w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true; boolean changed = false;
} if (req.getTitle() != null && !req.getTitle().isBlank()) {
if (req.getDescription() != null) { w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true;
w.set(GoalEntity::getDescription, req.getDescription()); changed = true; }
} if (req.getDescription() != null) {
if (req.getExitCriteria() != null) { w.set(GoalEntity::getDescription, req.getDescription()); changed = true;
w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true; }
} if (req.getExitCriteria() != null) {
if (req.getSuccessCheckPrompt() != null) { w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true;
w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true; }
} if (req.getSuccessCheckPrompt() != null) {
if (req.getTurnBudget() != null) { w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true;
validateBudget(req.getTurnBudget(), "turnBudget"); }
w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true; if (req.getTurnBudget() != null) {
} w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
if (req.getLlmCallBudget() != null) { }
validateBudget(req.getLlmCallBudget(), "llmCallBudget"); if (req.getLlmCallBudget() != null) {
w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true; w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true;
} }
if (req.getAutoFollowupEnabled() != null) { if (req.getAutoFollowupEnabled() != null) {
w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true; w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true;
} }
if (req.getFollowupCooldownSeconds() != null) { if (req.getFollowupCooldownSeconds() != null) {
w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds()); w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds());
changed = true; changed = true;
} }
if (!changed) { if (!changed) {
return g; return null; // idempotent no-op
} }
bumpVersionAndTime(w); bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "update"); return w;
recordAudit("goal.updated", g, Map.of("by", username)); });
return goalMapper.selectById(id); recordAudit("goal.updated", updated, Map.of("by", username));
return updated;
} }
@Override @Override
@ -251,31 +254,33 @@ public class GoalServiceImpl implements GoalService {
@Override @Override
@Transactional @Transactional
public GoalEntity abandon(Long id, String username) { public GoalEntity abandon(Long id, String username) {
GoalEntity g = getById(id);
ensureNotTerminal(g, "abandon");
// Allows abandon from both ACTIVE and PAUSED. // Allows abandon from both ACTIVE and PAUSED.
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) GoalEntity updated = retryOptimistic(id, "abandon", fresh -> {
.set(GoalEntity::getStatus, GoalStatus.ABANDONED); ensureNotTerminal(fresh, "abandon");
bumpVersionAndTime(w); LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
retryOptimistic(() -> goalMapper.update(null, w), "abandon"); .set(GoalEntity::getStatus, GoalStatus.ABANDONED);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username)); writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username));
recordAudit("goal.abandoned", g, Map.of("by", username)); recordAudit("goal.abandoned", updated, Map.of("by", username));
return goalMapper.selectById(id); return updated;
} }
@Override @Override
@Transactional @Transactional
public GoalEntity markCompleted(Long id, GoalEvaluationResult result) { public GoalEntity markCompleted(Long id, GoalEvaluationResult result) {
GoalEntity g = getById(id); GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> {
if (g.getStatus().isTerminal()) return g; // idempotent if (fresh.getStatus().isTerminal()) return null; // idempotent
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.COMPLETED); .set(GoalEntity::getStatus, GoalStatus.COMPLETED);
if (result != null) { if (result != null) {
w.set(GoalEntity::getCompletionScore, result.score()) w.set(GoalEntity::getCompletionScore, result.score())
.set(GoalEntity::getProgressSummary, result.gap()); .set(GoalEntity::getProgressSummary, result.gap());
} }
bumpVersionAndTime(w); bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "markCompleted"); return w;
});
Map<String, Object> detail = new LinkedHashMap<>(); Map<String, Object> detail = new LinkedHashMap<>();
detail.put("finalScore", result != null ? result.score() : null); detail.put("finalScore", result != null ? result.score() : null);
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed()); detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
@ -283,7 +288,7 @@ public class GoalServiceImpl implements GoalService {
writeEvent(id, GoalEventType.COMPLETED, null, detail); writeEvent(id, GoalEventType.COMPLETED, null, detail);
recordAudit("goal.completed", g, 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. // memory pipeline must not roll back the DB transition.
if (memoryManager != null) { if (memoryManager != null) {
try { try {
@ -300,18 +305,19 @@ public class GoalServiceImpl implements GoalService {
} }
} }
return goalMapper.selectById(id); return g;
} }
@Override @Override
@Transactional @Transactional
public GoalEntity markExhausted(Long id, String reason) { public GoalEntity markExhausted(Long id, String reason) {
GoalEntity g = getById(id); GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> {
if (g.getStatus().isTerminal()) return g; if (fresh.getStatus().isTerminal()) return null;
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.EXHAUSTED); .set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
bumpVersionAndTime(w); bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "markExhausted"); return w;
});
Map<String, Object> detail = new LinkedHashMap<>(); Map<String, Object> detail = new LinkedHashMap<>();
detail.put("reason", reason != null ? reason : "unknown"); detail.put("reason", reason != null ? reason : "unknown");
detail.put("turnsUsed", g.getTurnsUsed()); detail.put("turnsUsed", g.getTurnsUsed());
@ -319,7 +325,7 @@ public class GoalServiceImpl implements GoalService {
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed()); detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
writeEvent(id, GoalEventType.EXHAUSTED, null, detail); writeEvent(id, GoalEventType.EXHAUSTED, null, detail);
recordAudit("goal.exhausted", g, detail); recordAudit("goal.exhausted", g, detail);
return goalMapper.selectById(id); return g;
} }
// ==================== Evaluation bookkeeping ==================== // ==================== Evaluation bookkeeping ====================
@ -328,23 +334,30 @@ public class GoalServiceImpl implements GoalService {
@Transactional @Transactional
public void recordEvaluation(Long id, GoalEvaluationResult result, public void recordEvaluation(Long id, GoalEvaluationResult result,
int agentLlmCallsDelta, int evalLlmCallsDelta) { int agentLlmCallsDelta, int evalLlmCallsDelta) {
GoalEntity g = getById(id); // Cheap pre-check so terminal goals also skip the event write the
if (g.getStatus().isTerminal()) return; // ignore late evaluations // 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 agentDelta = Math.max(0, agentLlmCallsDelta);
int evalDelta = Math.max(0, evalLlmCallsDelta); int evalDelta = Math.max(0, evalLlmCallsDelta);
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) retryOptimistic(id, "recordEvaluation", fresh -> {
.setSql("turns_used = turns_used + 1") if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
.setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta) LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta) .setSql("turns_used = turns_used + 1")
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now()); .setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta)
if (result != null) { .setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta)
w.set(GoalEntity::getCompletionScore, result.score()) .set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
.set(GoalEntity::getProgressSummary, result.gap()); if (result != null) {
} w.set(GoalEntity::getCompletionScore, result.score())
bumpVersionAndTime(w); .set(GoalEntity::getProgressSummary, result.gap());
retryOptimistic(() -> goalMapper.update(null, w), "recordEvaluation"); }
bumpVersionAndTime(w);
return w;
});
Map<String, Object> detail = new LinkedHashMap<>(); Map<String, Object> detail = new LinkedHashMap<>();
if (result != null) { if (result != null) {
@ -379,12 +392,18 @@ public class GoalServiceImpl implements GoalService {
@Override @Override
@Transactional @Transactional
public void recordFollowupInjected(Long id, String prompt) { public void recordFollowupInjected(Long id, String prompt) {
GoalEntity g = getById(id); // Mirror recordEvaluation's pre-check so a late followup on a
if (g.getStatus().isTerminal()) return; // terminal goal does not produce an event-log entry.
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) GoalEntity initial = goalMapper.selectById(id);
.set(GoalEntity::getLastFollowupAt, LocalDateTime.now()); if (initial == null || initial.getStatus().isTerminal()) return;
bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), "recordFollowupInjected"); 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( writeEvent(id, GoalEventType.FOLLOWUP_INJECTED, null, Map.of(
"prompt", prompt != null ? prompt : "", "prompt", prompt != null ? prompt : "",
"turnsUsed", g.getTurnsUsed())); "turnsUsed", g.getTurnsUsed()));
@ -396,19 +415,22 @@ public class GoalServiceImpl implements GoalService {
if (criterion == null || criterion.isBlank()) { if (criterion == null || criterion.isBlank()) {
throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty"); throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty");
} }
GoalEntity g = getById(id); String trimmed = criterion.trim();
ensureNotTerminal(g, "appendCriterion"); // Merge against the freshly refetched criteria so a concurrent
String existing = g.getExitCriteria() != null ? g.getExitCriteria() : ""; // addCriterion never silently overwrites a sibling's append.
String merged = existing.isEmpty() ? criterion.trim() GoalEntity g = retryOptimistic(id, "appendCriterion", fresh -> {
: existing + "\n+ " + criterion.trim(); ensureNotTerminal(fresh, "appendCriterion");
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) String existing = fresh.getExitCriteria() != null ? fresh.getExitCriteria() : "";
.set(GoalEntity::getExitCriteria, merged); String merged = existing.isEmpty() ? trimmed : existing + "\n+ " + trimmed;
bumpVersionAndTime(w); LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
retryOptimistic(() -> goalMapper.update(null, w), "appendCriterion"); .set(GoalEntity::getExitCriteria, merged);
bumpVersionAndTime(w);
return w;
});
writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of( writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of(
"criterion", criterion.trim(), "criterion", trimmed,
"by", username)); "by", username));
return goalMapper.selectById(id); return g;
} }
// ==================== Internals ==================== // ==================== Internals ====================
@ -463,10 +485,48 @@ public class GoalServiceImpl implements GoalService {
.set(GoalEntity::getUpdateTime, LocalDateTime.now()); .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++) { for (int i = 0; i < OPTIMISTIC_LOCK_MAX_RETRIES; i++) {
int rows = update.getAsInt(); GoalEntity fresh = goalMapper.selectById(id);
if (rows > 0) return; 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 {}/{})", log.debug("[GoalService] Optimistic lock miss on {} (attempt {}/{})",
op, i + 1, OPTIMISTIC_LOCK_MAX_RETRIES); op, i + 1, OPTIMISTIC_LOCK_MAX_RETRIES);
} }
@ -475,21 +535,23 @@ public class GoalServiceImpl implements GoalService {
+ OPTIMISTIC_LOCK_MAX_RETRIES + " retries"); + OPTIMISTIC_LOCK_MAX_RETRIES + " retries");
} }
private GoalEntity flipStatus(Long id, GoalStatus from, GoalStatus to, private GoalEntity flipStatus(Long id, GoalStatus from, GoalStatus to,
String eventType, String auditAction, String username) { String eventType, String auditAction, String username) {
GoalEntity g = getById(id); GoalEntity g = retryOptimistic(id, to.getValue(), fresh -> {
if (g.getStatus() != from) { if (fresh.getStatus() != from) {
throw new MateClawException("err.goal.bad_transition", 409, throw new MateClawException("err.goal.bad_transition", 409,
"Cannot transition " + g.getStatus().getValue() + " -> " + to.getValue()); "Cannot transition " + fresh.getStatus().getValue() + " -> " + to.getValue());
} }
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g) LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, to); .set(GoalEntity::getStatus, to);
bumpVersionAndTime(w); bumpVersionAndTime(w);
retryOptimistic(() -> goalMapper.update(null, w), to.getValue()); return w;
});
writeEvent(id, eventType, null, Map.of("by", username, writeEvent(id, eventType, null, Map.of("by", username,
"from", from.getValue(), "to", to.getValue())); "from", from.getValue(), "to", to.getValue()));
recordAudit(auditAction, g, Map.of("by", username)); 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) { 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()) { if (next != null && !next.isEmpty()) {
injected = next; injected = next;
} else { } else {
injected = policy.emptyFallback; // For cross-turn messages, try the reasoning content cache
if (injected == null && policy.warnOnMissingReal) { // (real values from prior responses) before falling back to empty.
log.warn("[patchReasoningContent] provider={} requires real reasoning_content " injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx);
+ "but relay has no value for assistant message at index {}; " if (injected == null) {
+ "leaving null so provider returns explicit error.", injected = policy.emptyFallback;
providerIdOrUnknown(provider), i); 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) { if (injected == null && msg.reasoningContent() == null) {
@ -225,10 +230,11 @@ final class OpenAiRequestRewriter {
* OpenAI-compatible gateway) might still require the patch. * OpenAI-compatible gateway) might still require the patch.
*/ */
private enum FallbackPolicy { private enum FallbackPolicy {
DEEPSEEK(" ", false, true, true), DEEPSEEK (" ", false, true, true),
KIMI (" ", false, false, false), KIMI (" ", false, false, false),
OPENAI (" ", false, false, false), OPENAI (" ", false, false, false),
DEFAULT (" ", false, false, false); XIAOMI_MIMO (" ", false, true, true),
DEFAULT (" ", false, false, false);
final String emptyFallback; final String emptyFallback;
final boolean warnOnMissingReal; final boolean warnOnMissingReal;
@ -253,6 +259,7 @@ final class OpenAiRequestRewriter {
case "deepseek" -> DEEPSEEK; case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI; case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI; case "openai", "azure-openai" -> OPENAI;
case "xiaomi-mimo" -> XIAOMI_MIMO;
default -> DEFAULT; default -> DEFAULT;
}; };
} }
@ -306,6 +313,22 @@ final class OpenAiRequestRewriter {
return family.isThinking(); 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 ==================== // ==================== 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), 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" 但不匹配上述族 * 通用 thinking 模型名称含 "thinking" "reasoner" 但不匹配上述族
* qwen3-235b-a22b-thinking-2507 * qwen3-235b-a22b-thinking-2507
@ -160,6 +169,11 @@ public enum ModelFamily {
return DEEPSEEK_REASONER; return DEEPSEEK_REASONER;
} }
// Xiaomi MiMo thinking mimo-* / MiMo-VL-* 系列
if (normalized.startsWith("mimo")) {
return MIMO_THINKING;
}
// 通用 thinking 名称含 thinking / reasoner 关键词 // 通用 thinking 名称含 thinking / reasoner 关键词
if (normalized.contains("thinking") || normalized.contains("reasoner")) { if (normalized.contains("thinking") || normalized.contains("reasoner")) {
return GENERIC_THINKING; return GENERIC_THINKING;

View File

@ -1296,6 +1296,25 @@ public class ConversationService {
return username.equals(conv.getUsername()) || SYSTEM_USER.equals(conv.getUsername()); 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. * 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.model.GoalUpdateRequest;
import vip.mate.goal.service.GoalService; import vip.mate.goal.service.GoalService;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import java.util.Map; import java.util.Map;
@ -72,11 +73,21 @@ class GoalControllerTest {
return r; 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 ==================== // ==================== create ====================
@Test @Test
void create_succeeds_whenOwner() { void create_succeeds_whenOwner() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); 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"))) when(goalService.create(any(), eq("alice")))
.thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
R<GoalEntity> result = controller.create(req("conv-1"), auth); R<GoalEntity> result = controller.create(req("conv-1"), auth);
@ -84,6 +95,46 @@ class GoalControllerTest {
assertEquals(1L, result.getData().getId()); 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 @Test
void create_returns403_whenNotOwner() { void create_returns403_whenNotOwner() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false);

View File

@ -1,10 +1,25 @@
package vip.mate.goal.service; 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.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.config.GoalProperties;
import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult; import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalStatus; 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; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; 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 * Covers the LLM-backed evaluator: prompt construction is exercised
* paths. The LLM-backed evaluator lands in PR5; until then any "real" * via the integration with a mocked {@link ChatModel}, JSON parsing
* evaluation always returns continue without charging eval_llm_calls. * 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 { class GoalEvaluationServiceTest {
private final GoalProperties props = new GoalProperties(); @Mock private ModelConfigService modelConfigService;
private final GoalEvaluationService svc = new GoalEvaluationService(props); @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() { private GoalEntity goal() {
GoalEntity g = new GoalEntity(); GoalEntity g = new GoalEntity();
g.setId(1L); g.setId(1L);
g.setTitle("ship the blog"); g.setTitle("ship the blog");
g.setDescription("deploy to fly.io");
g.setExitCriteria("hello world page accessible");
g.setStatus(GoalStatus.ACTIVE); g.setStatus(GoalStatus.ACTIVE);
return g; 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 @Test
void nullGoal_returnsFallback() { void nullGoal_returnsFallback_withoutTouchingProviders() {
GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything"); GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
assertFalse(r.completed()); assertFalse(r.completed());
assertEquals(0, r.llmCallsConsumed()); assertEquals(0, r.llmCallsConsumed());
verify(chatModelFactory, never()).buildFor(any(), any());
} }
@Test @Test
void emptyAnswer_returnsFallback() { void emptyAnswer_returnsFallback_withoutTouchingProviders() {
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), ""); GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "");
assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision()); assertEquals(GoalEvaluationResult.DECISION_FALLBACK, r.decision());
verify(chatModelFactory, never()).buildFor(any(), any());
} }
@Test @Test
void normalCall_returnsContinue() { void noModelAvailable_returnsFallback() {
GoalEvaluationResult r = svc.evaluate(goal(), List.of(), "the answer text"); // Both lookup paths return null no default, no override.
assertNotNull(r); 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()); assertEquals(GoalEvaluationResult.DECISION_CONTINUE, r.decision());
assertFalse(r.completed()); assertFalse(r.completed());
assertEquals(0, r.llmCallsConsumed(), assertEquals(0.6, r.score(), 1e-9);
"PR2 stub must not charge eval_llm_calls — that path lands in PR5"); assertEquals("DNS not configured yet", r.gap());
} assertEquals(1, r.llmCallsConsumed());
@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("qwen-turbo", r.evaluatorModel()); 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 @Test
void fallback_doesNotChargeLlmCalls() { void fallback_doesNotChargeLlmCalls() {
GoalEvaluationResult r = GoalEvaluationResult.fallback("evaluator_unavailable"); GoalEvaluationResult r = GoalEvaluationResult.fallback("evaluator_unavailable");

View File

@ -331,6 +331,39 @@ class GoalServiceTest {
verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class)); 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 @Test
void findActiveByConversation_returnsNull_forBlankInput() { void findActiveByConversation_returnsNull_forBlankInput() {
assertNull(service.findActiveByConversation("")); assertNull(service.findActiveByConversation(""));

View File

@ -82,11 +82,13 @@ class PatchReasoningContentTest {
@BeforeEach @BeforeEach
void clearRelay() { void clearRelay() {
AssistantThinkingRelay.clearAll(); AssistantThinkingRelay.clearAll();
ReasoningContentCache.clear();
} }
@AfterEach @AfterEach
void clearRelayAfter() { void clearRelayAfter() {
AssistantThinkingRelay.clearAll(); AssistantThinkingRelay.clearAll();
ReasoningContentCache.clear();
} }
// ---------- No-relay, no-thinking-mode path ---------- // ---------- No-relay, no-thinking-mode path ----------
@ -427,4 +429,73 @@ class PatchReasoningContentTest {
assertEquals(" ", out.messages().get(3).reasoningContent(), assertEquals(" ", out.messages().get(3).reasoningContent(),
"DEEPSEEK plain in-turn assistant gets ' ' as before"); "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(""));
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");
}
} }