fix(agent): retry empty LLM completion before treating it as final answer

This commit is contained in:
matevip 2026-05-24 22:57:19 +08:00
parent 481cece733
commit c857d6dd45
2 changed files with 120 additions and 0 deletions

View File

@ -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<Message> 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 语义处理

View File

@ -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));
}
}