mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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;
|
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
|
* Tool-use enforcement clause appended to every ReasoningNode
|
||||||
* system prompt. Treats narration ("I will now …") as a protocol violation
|
* 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");
|
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) {
|
} catch (CancellationException ce) {
|
||||||
// "调用已发出但尚未产出内容时用户停止" — streamHelper 抛 CancellationException。
|
// "调用已发出但尚未产出内容时用户停止" — streamHelper 抛 CancellationException。
|
||||||
// 返回空 finalAnswer + STOPPED,让 FinalAnswerNode 按 STOPPED 语义处理。
|
// 返回空 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