fix(agent): recover tool-call follow-up on interleaved-thinking models (#256)

This commit is contained in:
matevip 2026-06-12 11:07:55 +08:00
parent 5235e30138
commit e6f8da9606
2 changed files with 231 additions and 20 deletions

View File

@ -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<Message> 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<Message> 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(

View File

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