diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 69f31c16..ff4c4c22 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -141,19 +141,96 @@ public class ReasoningNode implements NodeAction { + "required step is already done, output the final answer to the user now."; /** - * A turn carrying no tool call, no content, and no thinking is not a usable - * answer — it would route to the final-answer branch as an empty string and - * terminate the run. Fatal / prompt-too-long / partial results are handled by - * their own branches and must not be misread as "empty". + * Continuation nudge for the most common premature-stop pattern: an empty + * turn immediately after a successful tool call. The tool result is already + * in context but the model stopped before writing the user-facing answer + * (e.g. a download URL produced by a send-file tool). Anchoring the nudge to + * the tool result recovers the answer in the same run instead of leaving the + * user to send another message to resume. */ - static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) { + private static final String POST_TOOL_EMPTY_NUDGE = + "上一步工具已成功返回(结果在上文)。请基于工具结果直接给出面向用户的最终答复" + + "(例如下载地址 / 执行结论),不要停在思考阶段,也不要只描述\"接下来要做什么\"。"; + + /** + * Continuation nudge for a turn that carries reasoning/thinking but no + * visible content and no tool call. Interleaved-thinking models sometimes + * "decide" the task is done in their reasoning yet never emit the answer + * text; this re-prompts them to write it (or call the next tool). + */ + private static final String THINKING_ONLY_NUDGE = + "你已完成思考但还没有输出正文。请现在把面向用户的最终答案写出来;" + + "如果还有未完成的步骤,则立即调用对应工具。"; + + /** + * Why a no-tool-call reasoning turn cannot yet be accepted as a final answer. + * Both non-FINAL states route a turn into the bounded continuation-nudge loop + * instead of letting the final-answer branch emit an empty string and end the + * run prematurely. + */ + enum ContinuationIntent { + /** + * Real user-facing content present, or a tool call, or a failure owned by + * another branch (fatal / prompt-too-long / partial) — finalize normally. + */ + FINAL, + /** + * Reasoning/thinking present but no visible content and no tool call. The + * model "thought it was done" without writing the answer — common on + * interleaved-thinking models after a tool result. Nudge it to emit it. + */ + THINKING_ONLY, + /** No content, no thinking, no tool call — a fully blank turn. Nudge to continue. */ + BLANK, + } + + /** + * Classify a no-tool-call turn's continuation intent. Fatal / prompt-too-long + * / partial results are handled by their own branches and must not be misread + * as needing a nudge. + */ + static ContinuationIntent classifyContinuation(NodeStreamingChatHelper.StreamResult result) { if (result == null || result.hasToolCalls() || result.hasFatalError() || result.isPromptTooLong() || result.partial()) { - return false; + return ContinuationIntent.FINAL; } boolean noContent = result.text() == null || result.text().isBlank(); + if (!noContent) { + return ContinuationIntent.FINAL; + } boolean noThinking = result.thinking() == null || result.thinking().isBlank(); - return noContent && noThinking; + return noThinking ? ContinuationIntent.BLANK : ContinuationIntent.THINKING_ONLY; + } + + /** + * A fully blank turn (no tool call, no content, no thinking) is not a usable + * answer — it would route to the final-answer branch as an empty string and + * terminate the run. Retained as a thin predicate over {@link #classifyContinuation}. + */ + static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) { + return classifyContinuation(result) == ContinuationIntent.BLANK; + } + + /** + * True when the newest conversational turn in the model input is a tool + * response — i.e. the model is about to reason over a fresh tool result. Used + * to pick the result-anchored continuation nudge for the common "empty turn + * right after a tool call" stop pattern. + */ + static boolean lastTurnIsToolResponse(List messages) { + if (messages == null) { + return false; + } + for (int i = messages.size() - 1; i >= 0; i--) { + Message m = messages.get(i); + if (m instanceof ToolResponseMessage) { + return true; + } + if (m instanceof UserMessage || m instanceof AssistantMessage) { + return false; + } + } + return false; } /** @@ -677,22 +754,40 @@ public class ReasoningNode implements NodeAction { } } - // Empty-completion guard: a turn with no tool call, no content, and - // no thinking is not a real answer. Under heavy message-window - // trimming on long multi-step tasks the model occasionally emits a - // blank turn; the final-answer branch would then treat it as "done" - // (finalAnswer="") and end the run prematurely (observed: a 10-item - // research task stopping at item 2). Re-prompt it to continue — - // bounded, so a model that genuinely has nothing left still - // terminates cleanly through the normal empty-answer path below. + // Continuation guard: a no-tool-call turn with no visible content is + // not a real answer, whether it is fully blank or carries only + // reasoning. The final-answer branch would otherwise treat it as + // "done" (finalAnswer="") and end the run prematurely. Two shapes: + // BLANK — no content, no thinking, no tool call. Seen under + // heavy message-window trimming on long multi-step + // tasks (a 10-item research task stopping at item 2). + // THINKING_ONLY — reasoning present but no content and no tool call. + // Interleaved-thinking models "decide" they are done + // in their reasoning yet never emit the answer text; + // most often right after a tool result (e.g. a + // send-file tool succeeds but the download URL is + // never written, so the user has to send another + // message to resume). + // Re-prompt to continue — bounded, so a model that genuinely has + // nothing left still terminates cleanly through the normal + // empty-answer path below. When the newest turn is a tool result, an + // answer-anchored nudge recovers the user-facing reply in the same run. int emptyRetries = 0; - while (emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && isEmptyCompletion(result)) { + for (ContinuationIntent intent = classifyContinuation(result); + emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && intent != ContinuationIntent.FINAL; + intent = classifyContinuation(result)) { emptyRetries++; - log.warn("[ReasoningNode] Empty LLM completion (no tool call / content / thinking); " - + "nudging to continue (retry {}/{}), conv={}", - emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId); + boolean afterTool = lastTurnIsToolResponse(promptMessages); + String nudge = afterTool + ? POST_TOOL_EMPTY_NUDGE + : (intent == ContinuationIntent.THINKING_ONLY + ? THINKING_ONLY_NUDGE + : EMPTY_COMPLETION_NUDGE); + log.warn("[ReasoningNode] {} completion (afterTool={}); nudging to continue " + + "(retry {}/{}), conv={}", + intent, afterTool, emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId); List nudgedMessages = new ArrayList<>(promptMessages); - nudgedMessages.add(new UserMessage(EMPTY_COMPLETION_NUDGE)); + nudgedMessages.add(new UserMessage(nudge)); Prompt nudgePrompt = new Prompt(nudgedMessages, options); nextLlmCallCount++; result = streamingHelper.streamCall( diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeContinuationIntentTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeContinuationIntentTest.java new file mode 100644 index 00000000..14455dfc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeContinuationIntentTest.java @@ -0,0 +1,116 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.graph.NodeStreamingChatHelper.ErrorType; +import vip.mate.agent.graph.NodeStreamingChatHelper.StreamResult; +import vip.mate.agent.graph.node.ReasoningNode.ContinuationIntent; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ReasoningNode#classifyContinuation} and + * {@link ReasoningNode#lastTurnIsToolResponse} — the logic that decides whether + * a no-tool-call turn is a real final answer or an empty/thinking-only stop that + * must be nudged to continue. The headline case: an interleaved-thinking model + * that returns reasoning but no content after a successful tool call (e.g. a + * send-file tool succeeds but the download URL is never written) must be + * re-prompted in-run, not accepted as a final empty answer. + */ +class ReasoningNodeContinuationIntentTest { + + private static StreamResult turn(String text, String thinking, boolean hasToolCalls) { + return new StreamResult(text, thinking, null, List.of(), hasToolCalls, 0, 0); + } + + @Test + @DisplayName("Visible content → FINAL (real answer).") + void contentIsFinal() { + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(turn("here it is", "", false))); + // Content present even alongside thinking is still a real answer. + assertEquals(ContinuationIntent.FINAL, + ReasoningNode.classifyContinuation(turn("here it is", "let me reason", false))); + } + + @Test + @DisplayName("Tool call → FINAL (owned by the tool-call branch, not the nudge loop).") + void toolCallIsFinal() { + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(turn("", "", true))); + } + + @Test + @DisplayName("Reasoning but no content and no tool call → THINKING_ONLY (nudge to answer).") + void thinkingOnlyNeedsNudge() { + assertEquals(ContinuationIntent.THINKING_ONLY, + ReasoningNode.classifyContinuation(turn("", "I have the file, the task is done", false))); + assertEquals(ContinuationIntent.THINKING_ONLY, + ReasoningNode.classifyContinuation(turn(" ", "reasoning here", false))); + } + + @Test + @DisplayName("No content, no thinking, no tool call → BLANK (nudge to continue).") + void blankNeedsNudge() { + assertEquals(ContinuationIntent.BLANK, ReasoningNode.classifyContinuation(turn("", "", false))); + assertEquals(ContinuationIntent.BLANK, ReasoningNode.classifyContinuation(turn(null, null, false))); + } + + @Test + @DisplayName("null / fatal / prompt-too-long / partial → FINAL (handled by other branches).") + void otherStatesAreFinal() { + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(null)); + + StreamResult fatal = new StreamResult("", "", null, List.of(), false, 0, 0, + false, "upstream boom", ErrorType.SERVER_ERROR); + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(fatal)); + + StreamResult promptTooLong = new StreamResult("", "", null, List.of(), false, 0, 0, + false, null, ErrorType.PROMPT_TOO_LONG); + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(promptTooLong)); + + StreamResult partial = new StreamResult("", "", null, List.of(), false, 0, 0, + true, null, ErrorType.NONE); + assertEquals(ContinuationIntent.FINAL, ReasoningNode.classifyContinuation(partial)); + } + + @Test + @DisplayName("isEmptyCompletion stays true only for BLANK (backward compatible).") + void isEmptyCompletionIsBlankOnly() { + assertTrue(ReasoningNode.isEmptyCompletion(turn("", "", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "thinking", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("answer", "", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "", true))); + } + + @Test + @DisplayName("Newest turn being a tool response is detected → picks the answer-anchored nudge.") + void detectsTrailingToolResponse() { + List afterTool = List.of( + new UserMessage("send me the file"), + new AssistantMessage("calling send_file"), + ToolResponseMessage.builder().responses(List.of( + new ToolResponseMessage.ToolResponse("1", "send_file", "{\"url\":\"https://x/y\"}"))) + .build()); + assertTrue(ReasoningNode.lastTurnIsToolResponse(afterTool)); + } + + @Test + @DisplayName("A user/assistant turn after the tool result → not a tool-anchored stop.") + void noTrailingToolResponse() { + assertFalse(ReasoningNode.lastTurnIsToolResponse(List.of( + new UserMessage("hello")))); + assertFalse(ReasoningNode.lastTurnIsToolResponse(List.of( + ToolResponseMessage.builder().responses(List.of( + new ToolResponseMessage.ToolResponse("1", "send_file", "ok"))).build(), + new UserMessage("follow up")))); + assertFalse(ReasoningNode.lastTurnIsToolResponse(List.of())); + assertFalse(ReasoningNode.lastTurnIsToolResponse(null)); + } +}