mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): complete long-form responses reliably
This commit is contained in:
parent
e88be95cd2
commit
987bc2001a
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<String> 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<ToolCallback> filterLongFormArtifactTools(String userMessage,
|
||||
List<ToolCallback> 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<AssistantMessage.ToolCall> 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("</memory-context>");
|
||||
return memoryEnd >= 0
|
||||
? userMessage.substring(memoryEnd + "</memory-context>".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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, Object> state = baseStateMap();
|
||||
state.put(USER_MESSAGE, "帮我写个 5000 字的玄幻短篇小说,角色和剧情都你自己编。");
|
||||
Map<String, Object> 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<String, Object> state = baseStateMap();
|
||||
state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。");
|
||||
state.put(MAX_ITERATIONS, 100);
|
||||
Map<String, Object> 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<String, Object> state = baseStateMap();
|
||||
state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。");
|
||||
state.put(MAX_ITERATIONS, 100);
|
||||
state.put(CURRENT_ITERATION, 1);
|
||||
state.put("long_form_draft", firstChunk);
|
||||
|
||||
Map<String, Object> 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<String, Object> state = baseStateMap();
|
||||
state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。");
|
||||
state.put(MAX_ITERATIONS, 1);
|
||||
|
||||
Map<String, Object> 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<ToolCallback> 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<ToolCallback> 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 = """
|
||||
<memory-context>
|
||||
用户偏好 Word 文档、文件下载和保存到工作区。
|
||||
</memory-context>
|
||||
帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。
|
||||
""";
|
||||
|
||||
List<ToolCallback> 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 {
|
||||
|
||||
@ -317,7 +317,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('agents.fields.maxIterations') }}</label>
|
||||
<input v-model.number="form.maxIterations" type="number" min="1" max="50" class="form-input" />
|
||||
<input v-model.number="form.maxIterations" type="number" min="1" max="150" class="form-input" />
|
||||
</div>
|
||||
<!-- RFC-03 Lane G1: per-Agent model override. Empty value falls
|
||||
back to the global default in ModelConfigService.resolveModel. -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user