From 987bc2001a93ed9f2f157514907d5ba925b50ef1 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 24 Aug 2026 04:16:57 -0400 Subject: [PATCH] fix(agent): complete long-form responses reliably --- .../vip/mate/agent/AgentGraphBuilder.java | 1 + .../agent/graph/StateGraphReActAgent.java | 42 ++++- .../mate/agent/graph/node/ReasoningNode.java | 173 +++++++++++++++++- .../graph/state/MateClawStateAccessor.java | 8 + .../agent/graph/state/MateClawStateKeys.java | 2 + ...aphReActAgentStreamedContentDeltaTest.java | 23 +++ .../graph/node/ReasoningNodeOutputTest.java | 150 +++++++++++++++ mateclaw-ui/src/views/Agents.vue | 2 +- 8 files changed, 389 insertions(+), 12 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index e50e7f3a..c4fe1b07 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1069,6 +1069,7 @@ public class AgentGraphBuilder { // Summarizing .addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE) // 终止控制 .addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index d6b23486..8b6a98ef 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -310,10 +310,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); boolean completionRetry = output.state().value(CONTINUE_REASONING, false); - addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, - completionRetry || output.state().value(NEEDS_TOOL_CALL, false), - completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), - streamed)); + boolean longFormAccumulation = !output.state() + .value(LONG_FORM_DRAFT, "").isEmpty(); + String resolvedFinalAnswer = isFinalAnswerTurn + ? extractFinalAnswer(output) : ""; + if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation, + streamed, resolvedFinalAnswer)) { + addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, + completionRetry || output.state().value(NEEDS_TOOL_CALL, false), + completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), + streamed)); + } } if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { @@ -503,10 +510,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); boolean completionRetry = output.state().value(CONTINUE_REASONING, false); - addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, - completionRetry || output.state().value(NEEDS_TOOL_CALL, false), - completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), - streamed)); + boolean longFormAccumulation = !output.state() + .value(LONG_FORM_DRAFT, "").isEmpty(); + String resolvedFinalAnswer = isFinalAnswerTurn + ? extractFinalAnswer(output) : ""; + if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation, + streamed, resolvedFinalAnswer)) { + addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, + completionRetry || output.state().value(NEEDS_TOOL_CALL, false), + completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), + streamed)); + } } if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { @@ -633,6 +647,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC inputs.put(TOOL_CALL_COUNT, 0); inputs.put(ERROR_COUNT, 0); inputs.put(SHOULD_SUMMARIZE, false); + inputs.put(LONG_FORM_DRAFT, ""); inputs.put(LIMIT_EXCEEDED, false); inputs.put(CONTENT_STREAMED, false); inputs.put(THINKING_STREAMED, false); @@ -774,6 +789,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC return AgentService.StreamDelta.segmentOnly(streamed, null, kind); } + static boolean shouldEmitStreamedContent(boolean isFinalAnswerTurn, + boolean longFormAccumulation, + String streamed, + String finalAnswer) { + if (longFormAccumulation) { + return false; + } + return !isFinalAnswerTurn || finalAnswer == null || streamed == null + || !finalAnswer.contains(streamed); + } + private boolean hasFinalAnswer(NodeOutput output) { if (output == null || output.state() == null) { return false; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index bd7ffd1b..24147384 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -37,6 +37,8 @@ import vip.mate.team.service.TeamContextBuilder; import java.util.*; import java.util.concurrent.CancellationException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static vip.mate.agent.graph.state.MateClawStateKeys.*; @@ -137,6 +139,17 @@ public class ReasoningNode implements NodeAction { */ private static final int KEEP_RECENT_TOOL_RESPONSES = 3; + private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000; + private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile( + "(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)"); + private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile( + "(一万|1万|十千)\\s*(字|字符|中文字|汉字)"); + private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile( + "(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)"); + private static final List ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of( + "renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile", + "write_file", "local_write_file", "edit_file", "local_edit_file"); + /** Continuation nudge appended to the prompt when the model returns an empty turn. */ private static final String EMPTY_COMPLETION_NUDGE = "上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:" @@ -235,6 +248,100 @@ public class ReasoningNode implements NodeAction { return false; } + static OptionalInt requestedLongFormChars(String userMessage) { + if (userMessage == null || userMessage.isBlank()) { + return OptionalInt.empty(); + } + Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage); + if (tenThousand.find()) { + return OptionalInt.of(10_000); + } + Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage); + int best = 0; + while (matcher.find()) { + String rawNumber = matcher.group(1).replace(",", "").replace(",", ""); + double value; + try { + value = Double.parseDouble(rawNumber); + } catch (NumberFormatException ignored) { + continue; + } + String unit = matcher.group(2); + if ("万".equals(unit)) { + value *= 10_000; + } else if ("千".equals(unit) || "k".equals(unit) || "K".equals(unit)) { + value *= 1_000; + } + best = Math.max(best, (int) Math.round(value)); + } + return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty(); + } + + static List filterLongFormArtifactTools(String userMessage, + List callbacks) { + String currentRequest = currentUserRequest(userMessage); + if (callbacks == null || callbacks.isEmpty() + || requestedLongFormChars(currentRequest).isEmpty() + || EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) { + return callbacks; + } + return callbacks.stream() + .filter(callback -> { + String name = callback.getToolDefinition().name(); + return ARTIFACT_DELIVERY_TOOL_PREFIXES.stream().noneMatch(name::startsWith); + }) + .toList(); + } + + static boolean hasDisallowedLongFormArtifactCall(String userMessage, + List toolCalls) { + String currentRequest = currentUserRequest(userMessage); + if (toolCalls == null || toolCalls.isEmpty() + || requestedLongFormChars(currentRequest).isEmpty() + || EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) { + return false; + } + return toolCalls.stream().anyMatch(call -> ARTIFACT_DELIVERY_TOOL_PREFIXES.stream() + .anyMatch(prefix -> call.name().startsWith(prefix))); + } + + private static String currentUserRequest(String userMessage) { + if (userMessage == null) { + return ""; + } + int memoryEnd = userMessage.lastIndexOf(""); + return memoryEnd >= 0 + ? userMessage.substring(memoryEnd + "".length()).trim() + : userMessage; + } + + private static String appendLongFormChunk(String draft, String currentContent) { + return (draft != null ? draft : "") + (currentContent != null ? currentContent : ""); + } + + private static boolean shouldContinueLongForm(String userMessage, String longFormDraft, + String currentContent, int iteration, int maxIterations) { + OptionalInt requested = requestedLongFormChars(userMessage); + if (requested.isEmpty()) { + return false; + } + if (maxIterations > 0 && iteration + 1 >= maxIterations) { + return false; + } + return appendLongFormChunk(longFormDraft, currentContent).length() < requested.getAsInt(); + } + + private static UserMessage longFormContinuationPrompt(String userMessage, String longFormDraft, + String currentContent) { + int written = appendLongFormChunk(longFormDraft, currentContent).length(); + int requested = requestedLongFormChars(userMessage).orElse(0); + return new UserMessage(""" + [Runtime long-form continuation] + 用户明确要求长篇输出,目标约 %d 字;目前累计约 %d 字,尚未达到目标。 + 请从上一段结尾自然继续写,不要重写开头,不要总结,不要说明原因,直接续写正文。 + """.formatted(requested, written)); + } + /** * Tool-use enforcement clause appended to every ReasoningNode * system prompt. Treats narration ("I will now …") as a protocol violation @@ -857,6 +964,7 @@ public class ReasoningNode implements NodeAction { ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools) .activeCallbacks() : toolCallbacks; + activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks); ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); @@ -1142,6 +1250,34 @@ public class ReasoningNode implements NodeAction { } if (result.hasToolCalls()) { + if (hasDisallowedLongFormArtifactCall(accessor.userMessage(), result.toolCalls())) { + log.warn("[ReasoningNode] Rejecting artifact tool call for plain long-form response: {}", + result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList()); + UserMessage continuation = new UserMessage(""" + [Runtime long-form delivery gate] + The user requested the long-form text directly in chat and did not request a file, + document, attachment, export, or download. Do not call rendering or file-writing tools. + Continue writing the requested text directly in the response. + """); + return reasonOutput() + .continueReasoning(true) + .iterationCount(accessor.iterationCount() + 1) + .needsToolCall(false) + .shouldSummarize(false) + .toolCalls(List.of()) + .finalAnswer("") + .clearFinishReason() + .messages(List.of((Message) continuation)) + .currentPhase("reasoning") + .streamedContent("") + .streamedThinking(result.thinking()) + .contentStreamed(true) + .thinkingStreamed(!result.thinking().isEmpty()) + .llmCallCount(nextLlmCallCount) + .mergeUsage(state, result) + .events(buildEvents(phaseEvent, iterStartEvent)) + .build(); + } log.info("[ReasoningNode] LLM requested {} tool call(s): {}", result.toolCalls().size(), result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList()); @@ -1219,12 +1355,43 @@ public class ReasoningNode implements NodeAction { .build(); } log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0); + if (shouldContinueLongForm(accessor.userMessage(), accessor.longFormDraft(), content, + accessor.iterationCount(), accessor.maxIterations())) { + String accumulatedDraft = appendLongFormChunk(accessor.longFormDraft(), content); + int written = accumulatedDraft.length(); + int requested = requestedLongFormChars(accessor.userMessage()).orElse(0); + log.info("[ReasoningNode] Long-form answer below requested length ({} / {} chars), continuing", + written, requested); + return reasonOutput() + .continueReasoning(true) + .iterationCount(accessor.iterationCount() + 1) + .needsToolCall(false) + .shouldSummarize(false) + .finalAnswer("") + .longFormDraft(accumulatedDraft) + .clearFinishReason() + .messages(List.of((Message) result.assistantMessage(), + longFormContinuationPrompt(accessor.userMessage(), accessor.longFormDraft(), content))) + .currentPhase("reasoning") + .streamedContent(content != null ? content : "") + .streamedThinking(result.thinking()) + .contentStreamed(true) + .thinkingStreamed(!result.thinking().isEmpty()) + .llmCallCount(nextLlmCallCount) + .mergeUsage(state, result) + .events(buildEvents(phaseEvent, iterStartEvent)) + .build(); + } pushPhase(conversationId, "drafting_answer", Map.of( "iteration", accessor.iterationCount(), "answerChars", content != null ? content.length() : 0 )); + boolean longFormRequest = requestedLongFormChars(accessor.userMessage()).isPresent(); + String accumulatedContent = longFormRequest + ? appendLongFormChunk(accessor.longFormDraft(), content) + : (content != null ? content : ""); String answerWithSources = accessor.sourceEvidenceLedger() - .appendWikiSourceTable(content != null ? content : ""); + .appendWikiSourceTable(accumulatedContent); SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(answerWithSources); boolean evidenceInsufficient = !validation.valid(); @@ -1251,9 +1418,9 @@ public class ReasoningNode implements NodeAction { .finalThinking(result.thinking()) .messages(List.of((Message) result.assistantMessage())) .currentPhase("reasoning") - .streamedContent(evidenceInsufficient ? (content != null ? content : "") : "") + .streamedContent(evidenceInsufficient ? accumulatedContent : "") .finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL) - .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : "")) + .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, accumulatedContent)) .thinkingStreamed(!result.thinking().isEmpty()) .llmCallCount(nextLlmCallCount) .mergeUsage(state, result) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index bb8c2082..7ff1e43a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -119,6 +119,10 @@ public final class MateClawStateAccessor { return state.value(FINAL_ANSWER_DRAFT, ""); } + public String longFormDraft() { + return state.value(LONG_FORM_DRAFT, ""); + } + public boolean limitExceeded() { return state.value(LIMIT_EXCEEDED, false); } @@ -453,6 +457,10 @@ public final class MateClawStateAccessor { return put(FINAL_ANSWER_DRAFT, draft); } + public OutputBuilder longFormDraft(String draft) { + return put(LONG_FORM_DRAFT, draft); + } + // ---- 终止 ---- public OutputBuilder finalAnswer(String answer) { return put(FINAL_ANSWER, answer); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index 6c5e9743..47b60eea 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -64,6 +64,8 @@ public final class MateClawStateKeys { /** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */ public static final String FINAL_ANSWER_DRAFT = "final_answer_draft"; + /** Accumulated visible body for an explicit long-form generation request. */ + public static final String LONG_FORM_DRAFT = "long_form_draft"; /** 是否需要进入 summarizing 阶段 */ public static final String SHOULD_SUMMARIZE = "should_summarize"; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java index 88e31ebf..3028edff 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java @@ -166,4 +166,27 @@ class StateGraphReActAgentStreamedContentDeltaTest { assertEquals(1, deltas.size()); assertFalse(deltas.get(0).isEvent()); } + + @Test + @DisplayName("long-form chunks are not persisted separately from their combined final answer") + void longFormChunk_combinedFinalAnswerOwnsPersistence() { + assertFalse(StateGraphReActAgent.shouldEmitStreamedContent( + false, true, "chapter one", "")); + assertFalse(StateGraphReActAgent.shouldEmitStreamedContent( + true, true, "last chapter", "chapter one...last chapter")); + } + + @Test + @DisplayName("terminal streamed text already contained in final answer is not duplicated") + void normalTerminalContent_finalAnswerOwnsPersistence() { + assertFalse(StateGraphReActAgent.shouldEmitStreamedContent( + true, false, "answer", "answer")); + } + + @Test + @DisplayName("terminal body omitted from a warning-only final answer remains persistable") + void evidenceWarning_keepsSeparateBodyPersistence() { + assertTrue(StateGraphReActAgent.shouldEmitStreamedContent( + true, false, "unsupported answer body", "[证据不足] missing source")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java index acf25a25..b4f58a2b 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java @@ -105,6 +105,28 @@ class ReasoningNodeOutputTest { assertEquals("回答内容", output.get(FINAL_ANSWER)); } + @Test + @DisplayName("plain long-form requests reject hallucinated artifact tool calls") + void plainLongFormArtifactToolCall_continuesWithoutExecutingTool() throws Exception { + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall( + "docx-1", "function", "renderDocx", "{\"filename\":\"novel\"}"); + AssistantMessage assistant = AssistantMessage.builder() + .content("我将生成文档") + .toolCalls(List.of(toolCall)) + .build(); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "我将生成文档", "", assistant, List.of(toolCall), true, 100, 50); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 5000 字的玄幻短篇小说,角色和剧情都你自己编。"); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(NEEDS_TOOL_CALL)); + assertEquals(true, output.get(CONTINUE_REASONING)); + assertEquals(List.of(), output.get(TOOL_CALLS)); + } + @Test @DisplayName("action-required text-only candidate requests one reasoning continuation") void actionRequiredTextOnly_continuesOnce() throws Exception { @@ -141,6 +163,134 @@ class ReasoningNodeOutputTest { assertTrue(((String) output.get(FINAL_ANSWER)).contains("未观察到实际")); } + @Test + @DisplayName("long-form text request continues when generated content is far below requested length") + void longFormTextRequest_continuesUntilRequestedLength() throws Exception { + String partial = "玄".repeat(1200); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + partial, "", new AssistantMessage(partial), + List.of(), false, 100, 900); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。"); + state.put(MAX_ITERATIONS, 100); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(true, output.get(CONTINUE_REASONING)); + assertEquals("", output.get(FINAL_ANSWER)); + assertEquals(1, output.get(CURRENT_ITERATION)); + assertEquals(partial, output.get("long_form_draft"), + "Each continuation must retain the generated body for the terminal answer"); + List appended = (List) output.get(MESSAGES); + assertEquals(2, appended.size()); + assertTrue(appended.get(1) instanceof org.springframework.ai.chat.messages.UserMessage); + assertTrue(((org.springframework.ai.chat.messages.UserMessage) appended.get(1)).getText() + .contains("继续写"), + "Continuation prompt should ask the model to keep writing instead of ending the run"); + } + + @Test + @DisplayName("long-form continuation persists all chunks as one final answer") + void longFormTextRequest_combinesContinuationChunksInFinalAnswer() throws Exception { + String firstChunk = "甲".repeat(6000); + String finalChunk = "乙".repeat(4000); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + finalChunk, "", new AssistantMessage(finalChunk), + List.of(), false, 100, 900); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。"); + state.put(MAX_ITERATIONS, 100); + state.put(CURRENT_ITERATION, 1); + state.put("long_form_draft", firstChunk); + + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(CONTINUE_REASONING)); + assertEquals(firstChunk + finalChunk, output.get(FINAL_ANSWER)); + assertEquals(true, output.get(CONTENT_STREAMED), + "The combined answer was already streamed chunk by chunk and must not be broadcast twice"); + } + + @Test + @DisplayName("configured max iterations stops long-form continuation at the configured boundary") + void longFormTextRequest_honorsConfiguredMaxIterations() throws Exception { + String partial = "玄".repeat(1200); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + partial, "", new AssistantMessage(partial), + List.of(), false, 100, 900); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。"); + state.put(MAX_ITERATIONS, 1); + + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(CONTINUE_REASONING)); + assertEquals(partial, output.get(FINAL_ANSWER)); + } + + @Test + @DisplayName("long-form length parser accepts a grouped 10,000-character request") + void requestedLongFormChars_acceptsGroupedNumber() { + assertEquals(10_000, ReasoningNode.requestedLongFormChars("写一篇 10,000 字小说").orElseThrow()); + } + + @Test + @DisplayName("plain long-form writing stays inline and cannot terminate through artifact render tools") + void plainLongFormRequest_filtersArtifactDeliveryTools() { + ToolCallback renderDocx = mockTool("renderDocxFromFiles"); + ToolCallback writeFile = mockTool("write_file"); + ToolCallback progress = mockTool("progress_update"); + + List filtered = ReasoningNode.filterLongFormArtifactTools( + "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。", + List.of(renderDocx, writeFile, progress)); + + assertEquals(List.of(progress), filtered); + } + + @Test + @DisplayName("explicit document delivery keeps artifact render tools available") + void explicitLongFormDocumentRequest_keepsArtifactDeliveryTools() { + ToolCallback renderDocx = mockTool("renderDocxFromFiles"); + + List filtered = ReasoningNode.filterLongFormArtifactTools( + "写一篇 10000 字小说并生成 Word 文档给我下载。", + List.of(renderDocx)); + + assertEquals(List.of(renderDocx), filtered); + } + + @Test + @DisplayName("artifact words from injected memory do not override the current plain writing request") + void injectedMemoryArtifactPreference_doesNotKeepArtifactTools() { + ToolCallback writeFile = mockTool("write_file"); + String augmentedMessage = """ + + 用户偏好 Word 文档、文件下载和保存到工作区。 + + 帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。 + """; + + List filtered = ReasoningNode.filterLongFormArtifactTools( + augmentedMessage, List.of(writeFile)); + + assertTrue(filtered.isEmpty()); + } + + private static ToolCallback mockTool(String name) { + ToolCallback callback = mock(ToolCallback.class); + org.springframework.ai.tool.definition.ToolDefinition definition = + mock(org.springframework.ai.tool.definition.ToolDefinition.class); + when(definition.name()).thenReturn(name); + when(callback.getToolDefinition()).thenReturn(definition); + return callback; + } + @Test @DisplayName("failed action receipt overrides a model success claim") void failedActionReceipt_blocksSuccessClaim() throws Exception { diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index 4b8b4b62..5aa2c02b 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -317,7 +317,7 @@
- +