From 9e93c52d9a82cd76d729cec22717d6d3cfbf5e6f Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 21 May 2026 22:27:00 +0800 Subject: [PATCH] fix(goal): real evaluator, retry refactor, hardened node + extra edges --- .../vip/mate/agent/AgentGraphBuilder.java | 20 +- .../agent/graph/NodeStreamingChatHelper.java | 27 ++ .../agent/graph/node/GoalEvaluationNode.java | 99 +++++-- .../mate/goal/controller/GoalController.java | 17 ++ .../goal/service/GoalEvaluationService.java | 263 ++++++++++++++-- .../mate/goal/service/GoalServiceImpl.java | 280 +++++++++++------- .../llm/chatmodel/OpenAiRequestRewriter.java | 43 ++- .../llm/chatmodel/ReasoningContentCache.java | 112 +++++++ .../java/vip/mate/llm/model/ModelFamily.java | 14 + .../conversation/ConversationService.java | 19 ++ .../goal/controller/GoalControllerTest.java | 51 ++++ .../service/GoalEvaluationServiceTest.java | 222 ++++++++++++-- .../mate/goal/service/GoalServiceTest.java | 33 +++ .../chatmodel/PatchReasoningContentTest.java | 71 +++++ .../chatmodel/ReasoningContentCacheTest.java | 69 +++++ .../vip/mate/llm/model/ModelFamilyTest.java | 11 + 16 files changed, 1149 insertions(+), 202 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 7cde390c..34c733db 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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()) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 09018a14..988da757 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -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 toolCalls) { + if (fullThinking == null || fullThinking.isBlank()) return; + if (toolCalls == null || toolCalls.isEmpty()) return; + List 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} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 23db6a50..dfb70b23 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -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 followup = followupService.maybeBuildFollowup(refreshed, result); + Optional 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) diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java index aa87642e..2e84f595 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java @@ -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 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); } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java index cd562ba3..f82ca70c 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java @@ -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. * - *

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. + *

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. + * + *

Model selection: + *

    + *
  1. If {@code mateclaw.goal.evaluator-model} names an enabled model, + * use it.
  2. + *
  3. 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. + *
  4. + *
+ * + *

Prompt is short and JSON-only: the evaluator returns one object with + * {@code score} (0.0–1.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. * - *

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. + *

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 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 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 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\": ,\n") + .append(" \"gap\": \"\",\n") + .append(" \"completed\": \n") + .append("}"); + return sb.toString(); + } + + private String serializeMessages(List 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; } } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 7f3e0468..0a264479 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -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 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 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 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 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 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 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 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 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 w = baseLockedUpdate(fresh) + .set(GoalEntity::getStatus, GoalStatus.EXHAUSTED); + bumpVersionAndTime(w); + return w; + }); Map 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 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 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 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 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 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 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 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 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). + * + *

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 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 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 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 detail) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java index a2250b4e..38e932e6 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java @@ -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 ids = msg.toolCalls().stream() + .map(tc -> tc.id()) + .filter(id -> id != null && !id.isEmpty()) + .toList(); + return ReasoningContentCache.get(ids); + } + // ==================== reasoning_effort sanitizing ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java new file mode 100644 index 00000000..c6cfa2eb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/ReasoningContentCache.java @@ -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. + * + *

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 real reasoning content from prior + * responses instead of injecting empty strings, preserving model context. + * + *

Key design

+ *
    + *
  • Key: sorted, concatenated tool_call IDs from the assistant message. + * Tool call IDs are unique per response, so this key naturally + * disambiguates across turns.
  • + *
  • TTL: 24 hours (configurable). Entries older than TTL are lazily evicted + * on access and periodically during {@link #store}.
  • + *
  • Max entries: 10,000. Oldest entries evicted when exceeded.
  • + *
+ * + *

Usage

+ *
    + *
  1. Store: after a streaming response completes, call + * {@link #store} with the tool_call IDs and reasoning content.
  2. + *
  3. Retrieve: during request patching, call {@link #get} for + * cross-turn assistant messages to fill in cached reasoning.
  4. + *
+ * + * @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 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 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 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 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) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java index e9f42a5c..3511e4c0 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelFamily.java @@ -58,6 +58,15 @@ public enum ModelFamily { */ DEEPSEEK_V4_REASONING(false, false, true, false, false, true), + /** + * Xiaomi MiMo thinking 模型:MiMo-VL-*、mimo-* 系列。 + *

+ * MiMo 的 thinking 模式与 DeepSeek 类似:响应返回 {@code reasoning_content}, + * 后续多轮请求必须将 {@code reasoning_content} 传回,否则 API 返回 400 错误。 + * 约束:保留 max_tokens;不支持 reasoning_effort;temperature/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; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 0b311e21..c1a4167e 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -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. + * + *

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() + .eq(ConversationEntity::getConversationId, conversationId)); + } + /** * Get the persisted stream status for a conversation. * diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java index 6ad92928..f08fcc83 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java @@ -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 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 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); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java index 458fa972..ff5c6809 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java @@ -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"); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index 03e77485..a39cfc9d 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -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("")); diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java index f8c96473..6cde388c 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/PatchReasoningContentTest.java @@ -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)"); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java new file mode 100644 index 00000000..a5410199 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/ReasoningContentCacheTest.java @@ -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 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"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java index 707c0175..4b1fc07b 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java @@ -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"); + } }