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 19f826c1..1ac3c305 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 @@ -82,6 +82,36 @@ public class ReasoningNode implements NodeAction { */ private static final int DASHSCOPE_MAX_OUTPUT_TOKENS = 8192; + /** + * Max times to re-prompt the model when it returns a completely empty turn + * (no tool call, no content, no thinking) before accepting termination. + * A blank turn is otherwise treated as a final answer and ends the run; on + * long multi-step tasks that surfaces as the agent quitting mid-way. + */ + private static final int MAX_EMPTY_COMPLETION_RETRIES = 2; + + /** Continuation nudge appended to the prompt when the model returns an empty turn. */ + private static final String EMPTY_COMPLETION_NUDGE = + "Your previous turn was empty. If the task is not yet complete, continue now " + + "with the next concrete step — call a tool or write the next part. If every " + + "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". + */ + static boolean isEmptyCompletion(NodeStreamingChatHelper.StreamResult result) { + if (result == null || result.hasToolCalls() || result.hasFatalError() + || result.isPromptTooLong() || result.partial()) { + return false; + } + boolean noContent = result.text() == null || result.text().isBlank(); + boolean noThinking = result.thinking() == null || result.thinking().isBlank(); + return noContent && noThinking; + } + /** * Tool-use enforcement clause appended to every ReasoningNode * system prompt. Treats narration ("I will now …") as a protocol violation @@ -519,6 +549,28 @@ public class ReasoningNode implements NodeAction { log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry"); } } + + // 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. + int emptyRetries = 0; + while (emptyRetries < MAX_EMPTY_COMPLETION_RETRIES && isEmptyCompletion(result)) { + emptyRetries++; + log.warn("[ReasoningNode] Empty LLM completion (no tool call / content / thinking); " + + "nudging to continue (retry {}/{}), conv={}", + emptyRetries, MAX_EMPTY_COMPLETION_RETRIES, conversationId); + List nudgedMessages = new ArrayList<>(promptMessages); + nudgedMessages.add(new UserMessage(EMPTY_COMPLETION_NUDGE)); + Prompt nudgePrompt = new Prompt(nudgedMessages, options); + nextLlmCallCount++; + result = streamingHelper.streamCall( + chatModel, nudgePrompt, conversationId, "reasoning_empty_retry"); + } } catch (CancellationException ce) { // "调用已发出但尚未产出内容时用户停止" — streamHelper 抛 CancellationException。 // 返回空 finalAnswer + STOPPED,让 FinalAnswerNode 按 STOPPED 语义处理。 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeEmptyCompletionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeEmptyCompletionTest.java new file mode 100644 index 00000000..34ce9462 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeEmptyCompletionTest.java @@ -0,0 +1,68 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.NodeStreamingChatHelper.ErrorType; +import vip.mate.agent.graph.NodeStreamingChatHelper.StreamResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ReasoningNode#isEmptyCompletion} — the predicate that decides + * whether a model turn is a blank no-op worth re-prompting (vs a real answer, a + * tool call, or a failure handled by another branch). A blank turn must NOT be + * accepted as a final answer; that is what made a long multi-step task quit + * mid-way. + */ +class ReasoningNodeEmptyCompletionTest { + + private static StreamResult turn(String text, String thinking, boolean hasToolCalls) { + return new StreamResult(text, thinking, null, List.of(), hasToolCalls, 0, 0); + } + + @Test + @DisplayName("No tool call + blank text + blank thinking → empty (re-prompt).") + void blankTurnIsEmpty() { + assertTrue(ReasoningNode.isEmptyCompletion(turn("", "", false))); + assertTrue(ReasoningNode.isEmptyCompletion(turn(" ", " ", false))); + assertTrue(ReasoningNode.isEmptyCompletion(turn(null, null, false))); + } + + @Test + @DisplayName("Any content or thinking → not empty.") + void contentOrThinkingNotEmpty() { + assertFalse(ReasoningNode.isEmptyCompletion(turn("here is the answer", "", false))); + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "let me reason", false))); + } + + @Test + @DisplayName("A tool call is real progress → not empty.") + void toolCallNotEmpty() { + assertFalse(ReasoningNode.isEmptyCompletion(turn("", "", true))); + } + + @Test + @DisplayName("null result → not empty (nothing to re-prompt).") + void nullNotEmpty() { + assertFalse(ReasoningNode.isEmptyCompletion(null)); + } + + @Test + @DisplayName("Fatal / prompt-too-long / partial belong to other branches, not 'empty'.") + void otherFailuresNotEmpty() { + StreamResult fatal = new StreamResult("", "", null, List.of(), false, 0, 0, + false, "upstream boom", ErrorType.SERVER_ERROR); + assertFalse(ReasoningNode.isEmptyCompletion(fatal)); + + StreamResult promptTooLong = new StreamResult("", "", null, List.of(), false, 0, 0, + false, null, ErrorType.PROMPT_TOO_LONG); + assertFalse(ReasoningNode.isEmptyCompletion(promptTooLong)); + + StreamResult partial = new StreamResult("", "", null, List.of(), false, 0, 0, + true, null, ErrorType.NONE); + assertFalse(ReasoningNode.isEmptyCompletion(partial)); + } +}