mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): retry empty LLM completion before treating it as final answer
This commit is contained in:
parent
481cece733
commit
c857d6dd45
@ -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 语义处理。
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user