mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): recover tool-call follow-up on interleaved-thinking models (#256)
This commit is contained in:
parent
5235e30138
commit
e6f8da9606
@ -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(
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user