mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(goal): real evaluator, retry refactor, hardened node + extra edges
This commit is contained in:
parent
495a5c126d
commit
9e93c52d9a
@ -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())
|
||||
|
||||
@ -11,6 +11,7 @@ import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.llm.chatmodel.AssistantThinkingRelay;
|
||||
import vip.mate.llm.chatmodel.ReasoningContentCache;
|
||||
|
||||
import reactor.core.Disposable;
|
||||
|
||||
@ -1206,6 +1207,10 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
|
||||
|
||||
// Cache reasoning_content for MiMo-style providers that require it on
|
||||
// subsequent turns.
|
||||
cacheReasoningContent(fullThinking, finalToolCalls);
|
||||
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
@ -1235,6 +1240,10 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
|
||||
|
||||
// Cache reasoning_content for MiMo-style providers that require it on
|
||||
// subsequent turns. The cache replays real values instead of empty strings.
|
||||
cacheReasoningContent(fullThinking, finalToolCalls);
|
||||
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
@ -1268,6 +1277,24 @@ public class NodeStreamingChatHelper {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store reasoning content in the cache for cross-turn replay.
|
||||
* Only caches when there are tool calls (MiMo requires reasoning_content
|
||||
* specifically on assistant messages with tool_calls).
|
||||
*/
|
||||
private static void cacheReasoningContent(String fullThinking,
|
||||
List<AssistantMessage.ToolCall> toolCalls) {
|
||||
if (fullThinking == null || fullThinking.isBlank()) return;
|
||||
if (toolCalls == null || toolCalls.isEmpty()) return;
|
||||
List<String> ids = toolCalls.stream()
|
||||
.map(AssistantMessage.ToolCall::id)
|
||||
.filter(id -> id != null && !id.isEmpty())
|
||||
.toList();
|
||||
if (!ids.isEmpty()) {
|
||||
ReasoningContentCache.store(ids, fullThinking);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token / cache usage to the optional metrics aggregator.
|
||||
* Called only from successful assembly paths ({@link #assembleResult}
|
||||
|
||||
@ -137,47 +137,92 @@ public class GoalEvaluationNode implements NodeAction {
|
||||
recent = recent.subList(recent.size() - max, recent.size());
|
||||
}
|
||||
|
||||
GoalEvaluationResult result = evaluationService.evaluate(goal, recent, terminal);
|
||||
// Evaluator + persistence wrapped together: the just-emitted final
|
||||
// answer is the user-visible thing and must NOT be lost just because
|
||||
// a provider timeout or DB hiccup happens on the way to the
|
||||
// bookkeeping write. On any failure we mark the run as evaluated
|
||||
// (so the conditional edge above won't loop us back) and route to
|
||||
// the normal terminal path — the user still sees their answer; the
|
||||
// goal stays in whatever state it was before this turn.
|
||||
GoalEvaluationResult result;
|
||||
GoalEntity refreshed;
|
||||
try {
|
||||
result = evaluationService.evaluate(goal, recent, terminal);
|
||||
|
||||
// Pre-eval agent_llm count snapshot — the bookkeeping helper folds
|
||||
// it into the per-goal counter so future turns see growing usage.
|
||||
int agentLlmDelta = accessor.llmCallCount();
|
||||
int evalLlmDelta = result.llmCallsConsumed();
|
||||
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
|
||||
// Pre-eval agent_llm count snapshot — the bookkeeping helper
|
||||
// folds it into the per-goal counter so future turns see
|
||||
// growing usage.
|
||||
int agentLlmDelta = accessor.llmCallCount();
|
||||
int evalLlmDelta = result.llmCallsConsumed();
|
||||
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
|
||||
|
||||
GoalEntity refreshed = goalService.getById(goal.getId());
|
||||
|
||||
// Decision branches.
|
||||
if (result.completed() || result.score() >= 0.95) {
|
||||
goalService.markCompleted(refreshed.getId(), result);
|
||||
refreshed = goalService.getById(goal.getId());
|
||||
} catch (Throwable t) {
|
||||
log.warn("[GoalEvaluationNode] evaluator/persist failed for goal={} — skipping this pass: {}",
|
||||
goal.getId(), t.toString());
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluationResult(GoalEvaluationResult.fallback("node_exception").toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score()))))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (goalService.isBudgetExhausted(refreshed)) {
|
||||
String reason = goalService.exhaustionReason(refreshed);
|
||||
goalService.markExhausted(refreshed.getId(), reason);
|
||||
// Decision branches. Each terminal write is wrapped so a DB hiccup
|
||||
// (e.g. optimistic-lock conflict exceeding retries, memory sync
|
||||
// failure on completion) does not propagate into the chat graph
|
||||
// and abort the streamed answer the user already sees.
|
||||
try {
|
||||
if (result.completed() || result.score() >= 0.95) {
|
||||
goalService.markCompleted(refreshed.getId(), result);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_completed", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"score", result.score()))))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (goalService.isBudgetExhausted(refreshed)) {
|
||||
String reason = goalService.exhaustionReason(refreshed);
|
||||
goalService.markExhausted(refreshed.getId(), reason);
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"turnsUsed", refreshed.getTurnsUsed(),
|
||||
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
|
||||
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
|
||||
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
|
||||
"reason", reason))))
|
||||
.build();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
|
||||
refreshed.getId(), t.toString());
|
||||
return MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalEvaluatedThisRun(true)
|
||||
.events(List.of(goalEvent("goal_exhausted", Map.of(
|
||||
"goalId", String.valueOf(refreshed.getId()),
|
||||
"turnsUsed", refreshed.getTurnsUsed(),
|
||||
"agentLlmCallsUsed", refreshed.getAgentLlmCallsUsed(),
|
||||
"evalLlmCallsUsed", refreshed.getEvalLlmCallsUsed(),
|
||||
"totalLlmCallsUsed", refreshed.totalLlmCallsUsed(),
|
||||
"reason", reason))))
|
||||
.build();
|
||||
}
|
||||
|
||||
Optional<String> followup = followupService.maybeBuildFollowup(refreshed, result);
|
||||
Optional<String> followup;
|
||||
try {
|
||||
followup = followupService.maybeBuildFollowup(refreshed, result);
|
||||
} catch (Throwable t) {
|
||||
log.warn("[GoalEvaluationNode] followup planning failed for goal={}: {}",
|
||||
refreshed.getId(), t.toString());
|
||||
followup = Optional.empty();
|
||||
}
|
||||
if (followup.isPresent()) {
|
||||
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
||||
try {
|
||||
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
|
||||
} catch (Throwable t) {
|
||||
log.warn("[GoalEvaluationNode] recordFollowupInjected failed — emitting followup anyway: {}",
|
||||
t.toString());
|
||||
// Continue: the in-memory state-machine path still works
|
||||
// even if the audit row could not be written.
|
||||
}
|
||||
MateClawStateAccessor.OutputBuilder out = MateClawStateAccessor.output()
|
||||
.goalEvaluationResult(result.toMap())
|
||||
.goalFollowupInjected(true)
|
||||
|
||||
@ -21,6 +21,7 @@ import vip.mate.goal.model.GoalEventEntity;
|
||||
import vip.mate.goal.model.GoalUpdateRequest;
|
||||
import vip.mate.goal.service.GoalService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -53,6 +54,22 @@ public class GoalController {
|
||||
public R<GoalEntity> create(@RequestBody GoalCreateRequest req, Authentication auth) {
|
||||
String username = currentUsername(auth);
|
||||
requireOwner(req.getConversationId(), username);
|
||||
// Derive agentId/workspaceId from the conversation itself so the
|
||||
// caller cannot attribute the goal to an unrelated agent/workspace
|
||||
// they don't own. The request fields for these two are intentionally
|
||||
// ignored — audit, memory sync, listing, and future policy hooks all
|
||||
// assume the goal mirrors its conversation's identity.
|
||||
ConversationEntity conv = conversationService.findByConversationId(req.getConversationId());
|
||||
if (conv == null) {
|
||||
throw new MateClawException("err.goal.conversation_not_found", 404,
|
||||
"Conversation not found: " + req.getConversationId());
|
||||
}
|
||||
if (conv.getAgentId() == null) {
|
||||
throw new MateClawException("err.goal.conversation_has_no_agent", 409,
|
||||
"Conversation " + req.getConversationId() + " has no bound agent");
|
||||
}
|
||||
req.setAgentId(conv.getAgentId());
|
||||
req.setWorkspaceId(conv.getWorkspaceId() != null ? conv.getWorkspaceId() : 1L);
|
||||
GoalEntity g = goalService.create(req, username);
|
||||
return R.ok(g);
|
||||
}
|
||||
|
||||
@ -1,43 +1,95 @@
|
||||
package vip.mate.goal.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.goal.config.GoalProperties;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.model.GoalEvaluationResult;
|
||||
import vip.mate.llm.chatmodel.ProviderChatModelFactory;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Evaluates whether the agent's latest reply satisfies the goal's exit
|
||||
* criteria. PR2 ships a deterministic "always continue" stub plus a
|
||||
* passthrough completion gate; PR5 wires this through to a real LLM call.
|
||||
* Evaluates whether the assistant's latest reply satisfies a goal's exit
|
||||
* criteria. Drives the persistent-goal completion path (the
|
||||
* "auto-followup until score hits 1.0" loop), so it sits on the hot path
|
||||
* of every chat turn that has an active goal.
|
||||
*
|
||||
* <p>The split lets PR2 unblock the graph topology + dispatcher work
|
||||
* without committing to a particular evaluator model. The stub is
|
||||
* safe-by-default: it never marks a goal completed and never charges
|
||||
* eval_llm_calls.
|
||||
* <p>Returns a deterministic {@link GoalEvaluationResult#fallback fallback}
|
||||
* when the LLM call is unavailable, errors out, or returns un-parseable
|
||||
* JSON — the {@code GoalEvaluationNode} treats fallback as "skip
|
||||
* bookkeeping deltas, no event log, stay safe". This degrades cleanly
|
||||
* when the evaluator provider is misconfigured or transiently down.
|
||||
*
|
||||
* <p>Model selection:
|
||||
* <ol>
|
||||
* <li>If {@code mateclaw.goal.evaluator-model} names an enabled model,
|
||||
* use it.</li>
|
||||
* <li>Otherwise fall back to {@link ModelConfigService#getDefaultModel()}
|
||||
* — convenient for dev, but operators are encouraged to pin a cheap
|
||||
* evaluator-only model in production since this fires on every turn.
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Prompt is short and JSON-only: the evaluator returns one object with
|
||||
* {@code score} (0.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.
|
||||
*
|
||||
* <p>Returns {@link GoalEvaluationResult#fallback(String)} when the
|
||||
* evaluator is unavailable so the calling node can skip the bookkeeping
|
||||
* delta path. PR5 swaps this implementation with a real LLM call;
|
||||
* until then we never block the user on evaluator latency.
|
||||
* <p>Returns a {@link GoalEvaluationResult} carrying the score, gap
|
||||
* description, decision, model id, and elapsed latency. The
|
||||
* {@code llmCallsConsumed} field is 1 on success (one evaluator
|
||||
* call) and 0 on fallback paths so the per-goal LLM-call budget
|
||||
* stays accurate.
|
||||
*
|
||||
* @param goal the active goal under evaluation; never {@code null}
|
||||
* @param recentMessages most-recent N messages from the parent conversation
|
||||
* for context; the node already trims by
|
||||
* {@link GoalProperties#getEvaluatorContextMessages()}
|
||||
* @param terminalAnswer the assistant's just-emitted final answer text
|
||||
*/
|
||||
public GoalEvaluationResult evaluate(GoalEntity goal,
|
||||
List<?> recentMessages,
|
||||
List<? extends Message> recentMessages,
|
||||
String terminalAnswer) {
|
||||
if (goal == null) {
|
||||
return GoalEvaluationResult.fallback("no_goal");
|
||||
@ -45,21 +97,172 @@ public class GoalEvaluationService {
|
||||
if (terminalAnswer == null || terminalAnswer.isBlank()) {
|
||||
return GoalEvaluationResult.fallback("empty_answer");
|
||||
}
|
||||
// PR2 placeholder: deterministic continue with a passthrough gap.
|
||||
// Real evaluator lands in PR5 — see RFC 48 §3.9.
|
||||
String model = properties.getEvaluatorModel();
|
||||
if (model == null || model.isBlank()) {
|
||||
model = "stub";
|
||||
|
||||
ModelConfigEntity model = resolveEvaluatorModel();
|
||||
if (model == null) {
|
||||
log.warn("[GoalEvaluation] no evaluator model available (configured={}, default lookup empty)",
|
||||
properties.getEvaluatorModel());
|
||||
return GoalEvaluationResult.fallback("no_model");
|
||||
}
|
||||
log.debug("[GoalEvaluation] stub evaluate goal={} answerChars={} -> decision=continue",
|
||||
goal.getId(), terminalAnswer.length());
|
||||
return new GoalEvaluationResult(
|
||||
0.0,
|
||||
"(stub evaluator — wired in PR5)",
|
||||
GoalEvaluationResult.DECISION_CONTINUE,
|
||||
false,
|
||||
model,
|
||||
0,
|
||||
0L);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
ChatModel chatModel = chatModelFactory.buildFor(model, ONESHOT);
|
||||
String prompt = buildUserPrompt(goal, recentMessages, terminalAnswer);
|
||||
|
||||
List<Message> messages = new ArrayList<>(2);
|
||||
messages.add(new SystemMessage(SYSTEM_PROMPT));
|
||||
messages.add(new UserMessage(prompt));
|
||||
|
||||
ChatOptions options = ChatOptions.builder()
|
||||
.temperature(0.1)
|
||||
.maxTokens(MAX_OUTPUT_TOKENS)
|
||||
.build();
|
||||
|
||||
ChatResponse response = chatModel.call(new Prompt(messages, options));
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
|
||||
String body = extractText(response);
|
||||
if (body == null || body.isBlank()) {
|
||||
log.warn("[GoalEvaluation] empty response from evaluator model={}", model.getModelName());
|
||||
return GoalEvaluationResult.fallback("empty_response");
|
||||
}
|
||||
|
||||
return parseJson(body, model.getModelName(), elapsed);
|
||||
} catch (Throwable t) {
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
log.warn("[GoalEvaluation] evaluator call failed after {}ms: {}", elapsed, t.toString());
|
||||
return GoalEvaluationResult.fallback("call_failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Internals ====================
|
||||
|
||||
private ModelConfigEntity resolveEvaluatorModel() {
|
||||
String name = properties.getEvaluatorModel();
|
||||
if (name != null && !name.isBlank()) {
|
||||
// resolveModel returns the default model when the named one
|
||||
// can't be found, which is exactly the desired "graceful
|
||||
// degradation" semantics for a misconfigured evaluator id.
|
||||
return modelConfigService.resolveModel(name);
|
||||
}
|
||||
return modelConfigService.getDefaultModel();
|
||||
}
|
||||
|
||||
private static final String SYSTEM_PROMPT =
|
||||
"You are a goal-completion evaluator. You judge whether an AI "
|
||||
+ "assistant's latest reply satisfies a user's stated goal. "
|
||||
+ "Output exactly ONE JSON object with the keys score, gap, "
|
||||
+ "completed. No markdown, no commentary, no extra prose.";
|
||||
|
||||
private String buildUserPrompt(GoalEntity goal,
|
||||
List<? extends Message> recentMessages,
|
||||
String terminalAnswer) {
|
||||
StringBuilder sb = new StringBuilder(2048);
|
||||
sb.append("Goal title: ").append(safe(goal.getTitle())).append('\n');
|
||||
if (goal.getDescription() != null && !goal.getDescription().isBlank()) {
|
||||
sb.append("Goal description: ").append(safe(goal.getDescription())).append('\n');
|
||||
}
|
||||
if (goal.getExitCriteria() != null && !goal.getExitCriteria().isBlank()) {
|
||||
sb.append("Exit criteria:\n").append(safe(goal.getExitCriteria())).append('\n');
|
||||
}
|
||||
sb.append('\n');
|
||||
|
||||
if (recentMessages != null && !recentMessages.isEmpty()) {
|
||||
sb.append("Recent conversation (oldest first):\n");
|
||||
String convo = serializeMessages(recentMessages);
|
||||
if (convo.length() > MAX_CONVERSATION_CHARS) {
|
||||
convo = convo.substring(convo.length() - MAX_CONVERSATION_CHARS);
|
||||
}
|
||||
sb.append(convo).append('\n');
|
||||
}
|
||||
|
||||
String answer = terminalAnswer;
|
||||
if (answer.length() > MAX_TERMINAL_ANSWER_CHARS) {
|
||||
answer = answer.substring(0, MAX_TERMINAL_ANSWER_CHARS) + "\n... [truncated]";
|
||||
}
|
||||
sb.append("\nAssistant's latest final answer to evaluate:\n").append(answer).append('\n');
|
||||
|
||||
sb.append('\n')
|
||||
.append("Return exactly:\n")
|
||||
.append("{\n")
|
||||
.append(" \"score\": <number 0.0 to 1.0 — fraction of exit criteria satisfied>,\n")
|
||||
.append(" \"gap\": \"<short plain-text description of what's still missing; empty when score=1.0>\",\n")
|
||||
.append(" \"completed\": <true if every exit criterion is fully satisfied, else false>\n")
|
||||
.append("}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String serializeMessages(List<? extends Message> messages) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Message m : messages) {
|
||||
String role = m.getMessageType() != null ? m.getMessageType().getValue() : "msg";
|
||||
String text = m.getText();
|
||||
if (text == null) text = "";
|
||||
sb.append(role).append(": ").append(text.strip()).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String extractText(ChatResponse response) {
|
||||
if (response == null || response.getResult() == null
|
||||
|| response.getResult().getOutput() == null) {
|
||||
return null;
|
||||
}
|
||||
return response.getResult().getOutput().getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the evaluator's JSON output. The model may wrap the object in
|
||||
* ```json fences despite the system prompt telling it not to, so we
|
||||
* locate the first {@code {...}} substring and parse that. Anything
|
||||
* else (non-numeric score, missing fields, malformed JSON) downgrades
|
||||
* to a fallback result rather than throwing.
|
||||
*/
|
||||
private GoalEvaluationResult parseJson(String body, String modelName, long latencyMs) {
|
||||
String trimmed = body.strip();
|
||||
int braceStart = trimmed.indexOf('{');
|
||||
int braceEnd = trimmed.lastIndexOf('}');
|
||||
if (braceStart < 0 || braceEnd <= braceStart) {
|
||||
log.warn("[GoalEvaluation] no JSON object in evaluator output: {}",
|
||||
trimmed.length() > 200 ? trimmed.substring(0, 200) + "..." : trimmed);
|
||||
return GoalEvaluationResult.fallback("parse_no_object");
|
||||
}
|
||||
String json = trimmed.substring(braceStart, braceEnd + 1);
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
JsonNode scoreNode = node.get("score");
|
||||
if (scoreNode == null || !scoreNode.isNumber()) {
|
||||
return GoalEvaluationResult.fallback("parse_missing_score");
|
||||
}
|
||||
double score = clamp01(scoreNode.asDouble());
|
||||
String gap = node.hasNonNull("gap") ? node.get("gap").asText("") : "";
|
||||
boolean completed = node.hasNonNull("completed") && node.get("completed").asBoolean(false);
|
||||
// Belt-and-braces: a perfect score implies completion; let the
|
||||
// node's >= 0.95 threshold handle the gray zone.
|
||||
if (score >= 1.0 - 1e-9) completed = true;
|
||||
String decision = completed
|
||||
? GoalEvaluationResult.DECISION_COMPLETED
|
||||
: GoalEvaluationResult.DECISION_CONTINUE;
|
||||
return new GoalEvaluationResult(
|
||||
score, gap, decision, completed,
|
||||
modelName != null ? modelName : "", 1, latencyMs);
|
||||
} catch (Exception e) {
|
||||
log.warn("[GoalEvaluation] JSON parse failed: {} — body={}",
|
||||
e.getMessage(),
|
||||
json.length() > 200 ? json.substring(0, 200) + "..." : json);
|
||||
return GoalEvaluationResult.fallback("parse_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static double clamp01(double v) {
|
||||
if (Double.isNaN(v)) return 0.0;
|
||||
if (v < 0.0) return 0.0;
|
||||
if (v > 1.0) return 1.0;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +28,6 @@ import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
/**
|
||||
* Default implementation. Concurrency safety relies on:
|
||||
@ -183,45 +182,49 @@ public class GoalServiceImpl implements GoalService {
|
||||
@Override
|
||||
@Transactional
|
||||
public GoalEntity update(Long id, GoalUpdateRequest req, String username) {
|
||||
GoalEntity g = getById(id);
|
||||
ensureNotTerminal(g, "update");
|
||||
// Pre-validate constant fields once; the actual not-terminal check
|
||||
// happens inside the builder against the fresh entity so a status
|
||||
// flip between this method's entry and a CAS retry is honoured.
|
||||
if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget");
|
||||
if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget");
|
||||
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g);
|
||||
boolean changed = false;
|
||||
if (req.getTitle() != null && !req.getTitle().isBlank()) {
|
||||
w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true;
|
||||
}
|
||||
if (req.getDescription() != null) {
|
||||
w.set(GoalEntity::getDescription, req.getDescription()); changed = true;
|
||||
}
|
||||
if (req.getExitCriteria() != null) {
|
||||
w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true;
|
||||
}
|
||||
if (req.getSuccessCheckPrompt() != null) {
|
||||
w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true;
|
||||
}
|
||||
if (req.getTurnBudget() != null) {
|
||||
validateBudget(req.getTurnBudget(), "turnBudget");
|
||||
w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
|
||||
}
|
||||
if (req.getLlmCallBudget() != null) {
|
||||
validateBudget(req.getLlmCallBudget(), "llmCallBudget");
|
||||
w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true;
|
||||
}
|
||||
if (req.getAutoFollowupEnabled() != null) {
|
||||
w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true;
|
||||
}
|
||||
if (req.getFollowupCooldownSeconds() != null) {
|
||||
w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds());
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
return g;
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "update");
|
||||
recordAudit("goal.updated", g, Map.of("by", username));
|
||||
return goalMapper.selectById(id);
|
||||
GoalEntity updated = retryOptimistic(id, "update", fresh -> {
|
||||
ensureNotTerminal(fresh, "update");
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh);
|
||||
boolean changed = false;
|
||||
if (req.getTitle() != null && !req.getTitle().isBlank()) {
|
||||
w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true;
|
||||
}
|
||||
if (req.getDescription() != null) {
|
||||
w.set(GoalEntity::getDescription, req.getDescription()); changed = true;
|
||||
}
|
||||
if (req.getExitCriteria() != null) {
|
||||
w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true;
|
||||
}
|
||||
if (req.getSuccessCheckPrompt() != null) {
|
||||
w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true;
|
||||
}
|
||||
if (req.getTurnBudget() != null) {
|
||||
w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true;
|
||||
}
|
||||
if (req.getLlmCallBudget() != null) {
|
||||
w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true;
|
||||
}
|
||||
if (req.getAutoFollowupEnabled() != null) {
|
||||
w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true;
|
||||
}
|
||||
if (req.getFollowupCooldownSeconds() != null) {
|
||||
w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds());
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
return null; // idempotent no-op
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
recordAudit("goal.updated", updated, Map.of("by", username));
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -251,31 +254,33 @@ public class GoalServiceImpl implements GoalService {
|
||||
@Override
|
||||
@Transactional
|
||||
public GoalEntity abandon(Long id, String username) {
|
||||
GoalEntity g = getById(id);
|
||||
ensureNotTerminal(g, "abandon");
|
||||
// Allows abandon from both ACTIVE and PAUSED.
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.set(GoalEntity::getStatus, GoalStatus.ABANDONED);
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "abandon");
|
||||
GoalEntity updated = retryOptimistic(id, "abandon", fresh -> {
|
||||
ensureNotTerminal(fresh, "abandon");
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getStatus, GoalStatus.ABANDONED);
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username));
|
||||
recordAudit("goal.abandoned", g, Map.of("by", username));
|
||||
return goalMapper.selectById(id);
|
||||
recordAudit("goal.abandoned", updated, Map.of("by", username));
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public GoalEntity markCompleted(Long id, GoalEvaluationResult result) {
|
||||
GoalEntity g = getById(id);
|
||||
if (g.getStatus().isTerminal()) return g; // idempotent
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.set(GoalEntity::getStatus, GoalStatus.COMPLETED);
|
||||
if (result != null) {
|
||||
w.set(GoalEntity::getCompletionScore, result.score())
|
||||
.set(GoalEntity::getProgressSummary, result.gap());
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "markCompleted");
|
||||
GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> {
|
||||
if (fresh.getStatus().isTerminal()) return null; // idempotent
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getStatus, GoalStatus.COMPLETED);
|
||||
if (result != null) {
|
||||
w.set(GoalEntity::getCompletionScore, result.score())
|
||||
.set(GoalEntity::getProgressSummary, result.gap());
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("finalScore", result != null ? result.score() : null);
|
||||
detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed());
|
||||
@ -283,7 +288,7 @@ public class GoalServiceImpl implements GoalService {
|
||||
writeEvent(id, GoalEventType.COMPLETED, null, detail);
|
||||
recordAudit("goal.completed", g, detail);
|
||||
|
||||
// RFC 48 PR5 — forward to long-term memory. Best-effort: a failing
|
||||
// Forward to long-term memory on completion. Best-effort: a failing
|
||||
// memory pipeline must not roll back the DB transition.
|
||||
if (memoryManager != null) {
|
||||
try {
|
||||
@ -300,18 +305,19 @@ public class GoalServiceImpl implements GoalService {
|
||||
}
|
||||
}
|
||||
|
||||
return goalMapper.selectById(id);
|
||||
return g;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public GoalEntity markExhausted(Long id, String reason) {
|
||||
GoalEntity g = getById(id);
|
||||
if (g.getStatus().isTerminal()) return g;
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "markExhausted");
|
||||
GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> {
|
||||
if (fresh.getStatus().isTerminal()) return null;
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getStatus, GoalStatus.EXHAUSTED);
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("reason", reason != null ? reason : "unknown");
|
||||
detail.put("turnsUsed", g.getTurnsUsed());
|
||||
@ -319,7 +325,7 @@ public class GoalServiceImpl implements GoalService {
|
||||
detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed());
|
||||
writeEvent(id, GoalEventType.EXHAUSTED, null, detail);
|
||||
recordAudit("goal.exhausted", g, detail);
|
||||
return goalMapper.selectById(id);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ==================== Evaluation bookkeeping ====================
|
||||
@ -328,23 +334,30 @@ public class GoalServiceImpl implements GoalService {
|
||||
@Transactional
|
||||
public void recordEvaluation(Long id, GoalEvaluationResult result,
|
||||
int agentLlmCallsDelta, int evalLlmCallsDelta) {
|
||||
GoalEntity g = getById(id);
|
||||
if (g.getStatus().isTerminal()) return; // ignore late evaluations
|
||||
// Cheap pre-check so terminal goals also skip the event write — the
|
||||
// in-loop guard below still protects against a status flip during a
|
||||
// contended retry, but it would also issue the event log entry that
|
||||
// a no-op skip should not produce.
|
||||
GoalEntity initial = goalMapper.selectById(id);
|
||||
if (initial == null || initial.getStatus().isTerminal()) return;
|
||||
|
||||
int agentDelta = Math.max(0, agentLlmCallsDelta);
|
||||
int evalDelta = Math.max(0, evalLlmCallsDelta);
|
||||
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.setSql("turns_used = turns_used + 1")
|
||||
.setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta)
|
||||
.setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta)
|
||||
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
|
||||
if (result != null) {
|
||||
w.set(GoalEntity::getCompletionScore, result.score())
|
||||
.set(GoalEntity::getProgressSummary, result.gap());
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "recordEvaluation");
|
||||
retryOptimistic(id, "recordEvaluation", fresh -> {
|
||||
if (fresh.getStatus().isTerminal()) return null; // ignore late evaluations
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.setSql("turns_used = turns_used + 1")
|
||||
.setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta)
|
||||
.setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta)
|
||||
.set(GoalEntity::getLastEvaluationAt, LocalDateTime.now());
|
||||
if (result != null) {
|
||||
w.set(GoalEntity::getCompletionScore, result.score())
|
||||
.set(GoalEntity::getProgressSummary, result.gap());
|
||||
}
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
if (result != null) {
|
||||
@ -379,12 +392,18 @@ public class GoalServiceImpl implements GoalService {
|
||||
@Override
|
||||
@Transactional
|
||||
public void recordFollowupInjected(Long id, String prompt) {
|
||||
GoalEntity g = getById(id);
|
||||
if (g.getStatus().isTerminal()) return;
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.set(GoalEntity::getLastFollowupAt, LocalDateTime.now());
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "recordFollowupInjected");
|
||||
// Mirror recordEvaluation's pre-check so a late followup on a
|
||||
// terminal goal does not produce an event-log entry.
|
||||
GoalEntity initial = goalMapper.selectById(id);
|
||||
if (initial == null || initial.getStatus().isTerminal()) return;
|
||||
|
||||
GoalEntity g = retryOptimistic(id, "recordFollowupInjected", fresh -> {
|
||||
if (fresh.getStatus().isTerminal()) return null;
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getLastFollowupAt, LocalDateTime.now());
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
writeEvent(id, GoalEventType.FOLLOWUP_INJECTED, null, Map.of(
|
||||
"prompt", prompt != null ? prompt : "",
|
||||
"turnsUsed", g.getTurnsUsed()));
|
||||
@ -396,19 +415,22 @@ public class GoalServiceImpl implements GoalService {
|
||||
if (criterion == null || criterion.isBlank()) {
|
||||
throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty");
|
||||
}
|
||||
GoalEntity g = getById(id);
|
||||
ensureNotTerminal(g, "appendCriterion");
|
||||
String existing = g.getExitCriteria() != null ? g.getExitCriteria() : "";
|
||||
String merged = existing.isEmpty() ? criterion.trim()
|
||||
: existing + "\n+ " + criterion.trim();
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.set(GoalEntity::getExitCriteria, merged);
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), "appendCriterion");
|
||||
String trimmed = criterion.trim();
|
||||
// Merge against the freshly refetched criteria so a concurrent
|
||||
// addCriterion never silently overwrites a sibling's append.
|
||||
GoalEntity g = retryOptimistic(id, "appendCriterion", fresh -> {
|
||||
ensureNotTerminal(fresh, "appendCriterion");
|
||||
String existing = fresh.getExitCriteria() != null ? fresh.getExitCriteria() : "";
|
||||
String merged = existing.isEmpty() ? trimmed : existing + "\n+ " + trimmed;
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getExitCriteria, merged);
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of(
|
||||
"criterion", criterion.trim(),
|
||||
"criterion", trimmed,
|
||||
"by", username));
|
||||
return goalMapper.selectById(id);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ==================== Internals ====================
|
||||
@ -463,10 +485,48 @@ public class GoalServiceImpl implements GoalService {
|
||||
.set(GoalEntity::getUpdateTime, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private void retryOptimistic(IntSupplier update, String op) {
|
||||
/**
|
||||
* Builder that takes the just-refetched goal entity and either returns a
|
||||
* fully prepared {@link LambdaUpdateWrapper} (with version pinned to the
|
||||
* fresh row) or returns {@code null} to signal "no-op, treat as success"
|
||||
* for idempotent skips (e.g. terminal-state guards).
|
||||
*/
|
||||
@FunctionalInterface
|
||||
private interface UpdateBuilder {
|
||||
LambdaUpdateWrapper<GoalEntity> build(GoalEntity fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch + rebuild + CAS loop. The builder is invoked on each attempt
|
||||
* against a freshly loaded entity so the {@code WHERE version=?} clause
|
||||
* always matches the current row version. Returns the post-update entity
|
||||
* (or the unchanged fresh entity when the builder signals a no-op).
|
||||
*
|
||||
* <p>This replaces the earlier single-shot wrapper capture which could
|
||||
* not recover from the very first CAS miss — once {@code oldVersion}
|
||||
* went stale, all subsequent retries with the same wrapper were
|
||||
* doomed. Refetching per attempt is the correct shape for optimistic
|
||||
* locking: read, build delta against the read, CAS on the read's
|
||||
* version.
|
||||
*/
|
||||
private GoalEntity retryOptimistic(Long id, String op, UpdateBuilder builder) {
|
||||
for (int i = 0; i < OPTIMISTIC_LOCK_MAX_RETRIES; i++) {
|
||||
int rows = update.getAsInt();
|
||||
if (rows > 0) return;
|
||||
GoalEntity fresh = goalMapper.selectById(id);
|
||||
if (fresh == null) {
|
||||
throw new MateClawException("err.goal.not_found", 404,
|
||||
"Goal not found: " + id);
|
||||
}
|
||||
LambdaUpdateWrapper<GoalEntity> w = builder.build(fresh);
|
||||
if (w == null) {
|
||||
// Builder declined to issue a write (idempotent skip). Treat
|
||||
// as success — callers like markCompleted hit this when the
|
||||
// goal already moved to a terminal state via another path.
|
||||
return fresh;
|
||||
}
|
||||
int rows = goalMapper.update(null, w);
|
||||
if (rows > 0) {
|
||||
return goalMapper.selectById(id);
|
||||
}
|
||||
log.debug("[GoalService] Optimistic lock miss on {} (attempt {}/{})",
|
||||
op, i + 1, OPTIMISTIC_LOCK_MAX_RETRIES);
|
||||
}
|
||||
@ -475,21 +535,23 @@ public class GoalServiceImpl implements GoalService {
|
||||
+ OPTIMISTIC_LOCK_MAX_RETRIES + " retries");
|
||||
}
|
||||
|
||||
|
||||
private GoalEntity flipStatus(Long id, GoalStatus from, GoalStatus to,
|
||||
String eventType, String auditAction, String username) {
|
||||
GoalEntity g = getById(id);
|
||||
if (g.getStatus() != from) {
|
||||
throw new MateClawException("err.goal.bad_transition", 409,
|
||||
"Cannot transition " + g.getStatus().getValue() + " -> " + to.getValue());
|
||||
}
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(g)
|
||||
.set(GoalEntity::getStatus, to);
|
||||
bumpVersionAndTime(w);
|
||||
retryOptimistic(() -> goalMapper.update(null, w), to.getValue());
|
||||
GoalEntity g = retryOptimistic(id, to.getValue(), fresh -> {
|
||||
if (fresh.getStatus() != from) {
|
||||
throw new MateClawException("err.goal.bad_transition", 409,
|
||||
"Cannot transition " + fresh.getStatus().getValue() + " -> " + to.getValue());
|
||||
}
|
||||
LambdaUpdateWrapper<GoalEntity> w = baseLockedUpdate(fresh)
|
||||
.set(GoalEntity::getStatus, to);
|
||||
bumpVersionAndTime(w);
|
||||
return w;
|
||||
});
|
||||
writeEvent(id, eventType, null, Map.of("by", username,
|
||||
"from", from.getValue(), "to", to.getValue()));
|
||||
recordAudit(auditAction, g, Map.of("by", username));
|
||||
return goalMapper.selectById(id);
|
||||
return g;
|
||||
}
|
||||
|
||||
private void writeEvent(Long goalId, String type, Long messageId, Map<String, Object> detail) {
|
||||
|
||||
@ -137,12 +137,17 @@ final class OpenAiRequestRewriter {
|
||||
if (next != null && !next.isEmpty()) {
|
||||
injected = next;
|
||||
} else {
|
||||
injected = policy.emptyFallback;
|
||||
if (injected == null && policy.warnOnMissingReal) {
|
||||
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
|
||||
+ "but relay has no value for assistant message at index {}; "
|
||||
+ "leaving null so provider returns explicit error.",
|
||||
providerIdOrUnknown(provider), i);
|
||||
// For cross-turn messages, try the reasoning content cache
|
||||
// (real values from prior responses) before falling back to empty.
|
||||
injected = resolveCrossTurnReasoning(msg, i <= lastUserIdx);
|
||||
if (injected == null) {
|
||||
injected = policy.emptyFallback;
|
||||
if (injected == null && policy.warnOnMissingReal) {
|
||||
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
|
||||
+ "but relay has no value for assistant message at index {}; "
|
||||
+ "leaving null so provider returns explicit error.",
|
||||
providerIdOrUnknown(provider), i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (injected == null && msg.reasoningContent() == null) {
|
||||
@ -225,10 +230,11 @@ final class OpenAiRequestRewriter {
|
||||
* OpenAI-compatible gateway) might still require the patch.
|
||||
*/
|
||||
private enum FallbackPolicy {
|
||||
DEEPSEEK(" ", false, true, true),
|
||||
KIMI (" ", false, false, false),
|
||||
OPENAI (" ", false, false, false),
|
||||
DEFAULT (" ", false, false, false);
|
||||
DEEPSEEK (" ", false, true, true),
|
||||
KIMI (" ", false, false, false),
|
||||
OPENAI (" ", false, false, false),
|
||||
XIAOMI_MIMO (" ", false, true, true),
|
||||
DEFAULT (" ", false, false, false);
|
||||
|
||||
final String emptyFallback;
|
||||
final boolean warnOnMissingReal;
|
||||
@ -253,6 +259,7 @@ final class OpenAiRequestRewriter {
|
||||
case "deepseek" -> DEEPSEEK;
|
||||
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
|
||||
case "openai", "azure-openai" -> OPENAI;
|
||||
case "xiaomi-mimo" -> XIAOMI_MIMO;
|
||||
default -> DEFAULT;
|
||||
};
|
||||
}
|
||||
@ -306,6 +313,22 @@ final class OpenAiRequestRewriter {
|
||||
return family.isThinking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up cached reasoning content for cross-turn assistant messages.
|
||||
* Returns the cached value, or {@code null} if no cache hit (caller falls
|
||||
* back to the policy's empty fallback).
|
||||
*/
|
||||
private static String resolveCrossTurnReasoning(
|
||||
OpenAiApi.ChatCompletionMessage msg, boolean isCrossTurn) {
|
||||
if (!isCrossTurn) return null;
|
||||
if (msg.toolCalls() == null || msg.toolCalls().isEmpty()) return null;
|
||||
List<String> ids = msg.toolCalls().stream()
|
||||
.map(tc -> tc.id())
|
||||
.filter(id -> id != null && !id.isEmpty())
|
||||
.toList();
|
||||
return ReasoningContentCache.get(ids);
|
||||
}
|
||||
|
||||
// ==================== reasoning_effort sanitizing ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -58,6 +58,15 @@ public enum ModelFamily {
|
||||
*/
|
||||
DEEPSEEK_V4_REASONING(false, false, true, false, false, true),
|
||||
|
||||
/**
|
||||
* Xiaomi MiMo thinking 模型:MiMo-VL-*、mimo-* 系列。
|
||||
* <p>
|
||||
* MiMo 的 thinking 模式与 DeepSeek 类似:响应返回 {@code reasoning_content},
|
||||
* 后续多轮请求必须将 {@code reasoning_content} 传回,否则 API 返回 400 错误。
|
||||
* 约束:保留 max_tokens;不支持 reasoning_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;
|
||||
|
||||
@ -1296,6 +1296,25 @@ public class ConversationService {
|
||||
return username.equals(conv.getUsername()) || SYSTEM_USER.equals(conv.getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a conversation by its string (UUID-style) id, returning the
|
||||
* full entity or {@code null} when not found. Read-only — does not
|
||||
* create or mutate.
|
||||
*
|
||||
* <p>Callers that need to derive {@code agentId} / {@code workspaceId}
|
||||
* from a conversation (so the request cannot lie about either) should
|
||||
* use this rather than re-running the {@code LambdaQueryWrapper}
|
||||
* boilerplate inline.
|
||||
*/
|
||||
public ConversationEntity findByConversationId(String conversationId) {
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return conversationMapper.selectOne(
|
||||
new LambdaQueryWrapper<ConversationEntity>()
|
||||
.eq(ConversationEntity::getConversationId, conversationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the persisted stream status for a conversation.
|
||||
*
|
||||
|
||||
@ -14,6 +14,7 @@ import vip.mate.goal.model.GoalStatus;
|
||||
import vip.mate.goal.model.GoalUpdateRequest;
|
||||
import vip.mate.goal.service.GoalService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@ -72,11 +73,21 @@ class GoalControllerTest {
|
||||
return r;
|
||||
}
|
||||
|
||||
private ConversationEntity conv(String convId, Long agentId, Long workspaceId) {
|
||||
ConversationEntity c = new ConversationEntity();
|
||||
c.setConversationId(convId);
|
||||
c.setUsername("alice");
|
||||
c.setAgentId(agentId);
|
||||
c.setWorkspaceId(workspaceId);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ==================== create ====================
|
||||
|
||||
@Test
|
||||
void create_succeeds_whenOwner() {
|
||||
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
|
||||
when(conversationService.findByConversationId("conv-1")).thenReturn(conv("conv-1", 10L, 1L));
|
||||
when(goalService.create(any(), eq("alice")))
|
||||
.thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
|
||||
R<GoalEntity> result = controller.create(req("conv-1"), auth);
|
||||
@ -84,6 +95,46 @@ class GoalControllerTest {
|
||||
assertEquals(1L, result.getData().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_overridesAgentAndWorkspace_fromConversation() {
|
||||
// Request claims agentId=99 / workspaceId=77 — the controller must
|
||||
// ignore those and use the conversation's own bindings instead.
|
||||
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
|
||||
when(conversationService.findByConversationId("conv-1")).thenReturn(conv("conv-1", 42L, 7L));
|
||||
when(goalService.create(any(), eq("alice")))
|
||||
.thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE));
|
||||
GoalCreateRequest r = req("conv-1");
|
||||
r.setAgentId(99L);
|
||||
r.setWorkspaceId(77L);
|
||||
controller.create(r, auth);
|
||||
org.mockito.ArgumentCaptor<GoalCreateRequest> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(GoalCreateRequest.class);
|
||||
verify(goalService).create(captor.capture(), eq("alice"));
|
||||
assertEquals(42L, captor.getValue().getAgentId());
|
||||
assertEquals(7L, captor.getValue().getWorkspaceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_returns404_whenConversationMissing() {
|
||||
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
|
||||
when(conversationService.findByConversationId("conv-1")).thenReturn(null);
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> controller.create(req("conv-1"), auth));
|
||||
assertEquals(404, ex.getCode());
|
||||
verify(goalService, never()).create(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_returns409_whenConversationHasNoAgent() {
|
||||
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
|
||||
when(conversationService.findByConversationId("conv-1"))
|
||||
.thenReturn(conv("conv-1", null, 1L));
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> controller.create(req("conv-1"), auth));
|
||||
assertEquals(409, ex.getCode());
|
||||
verify(goalService, never()).create(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_returns403_whenNotOwner() {
|
||||
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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(""));
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user