mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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
|
// Summarizing
|
||||||
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
|
||||||
|
.addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE)
|
||||||
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
|
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
|
||||||
// 终止控制
|
// 终止控制
|
||||||
.addStrategy(MateClawStateKeys.FINISH_REASON, 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())) {
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
lastEmittedStreamedContent.set(streamed);
|
lastEmittedStreamedContent.set(streamed);
|
||||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
||||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
boolean longFormAccumulation = !output.state()
|
||||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
.value(LONG_FORM_DRAFT, "").isEmpty();
|
||||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
String resolvedFinalAnswer = isFinalAnswerTurn
|
||||||
streamed));
|
? 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)) {
|
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
|
||||||
@ -503,10 +510,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
|
||||||
lastEmittedStreamedContent.set(streamed);
|
lastEmittedStreamedContent.set(streamed);
|
||||||
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
boolean completionRetry = output.state().value(CONTINUE_REASONING, false);
|
||||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
boolean longFormAccumulation = !output.state()
|
||||||
completionRetry || output.state().value(NEEDS_TOOL_CALL, false),
|
.value(LONG_FORM_DRAFT, "").isEmpty();
|
||||||
completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0),
|
String resolvedFinalAnswer = isFinalAnswerTurn
|
||||||
streamed));
|
? 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)) {
|
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(TOOL_CALL_COUNT, 0);
|
||||||
inputs.put(ERROR_COUNT, 0);
|
inputs.put(ERROR_COUNT, 0);
|
||||||
inputs.put(SHOULD_SUMMARIZE, false);
|
inputs.put(SHOULD_SUMMARIZE, false);
|
||||||
|
inputs.put(LONG_FORM_DRAFT, "");
|
||||||
inputs.put(LIMIT_EXCEEDED, false);
|
inputs.put(LIMIT_EXCEEDED, false);
|
||||||
inputs.put(CONTENT_STREAMED, false);
|
inputs.put(CONTENT_STREAMED, false);
|
||||||
inputs.put(THINKING_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);
|
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) {
|
private boolean hasFinalAnswer(NodeOutput output) {
|
||||||
if (output == null || output.state() == null) {
|
if (output == null || output.state() == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -37,6 +37,8 @@ import vip.mate.team.service.TeamContextBuilder;
|
|||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
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 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. */
|
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
||||||
private static final String EMPTY_COMPLETION_NUDGE =
|
private static final String EMPTY_COMPLETION_NUDGE =
|
||||||
"上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:"
|
"上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:"
|
||||||
@ -235,6 +248,100 @@ public class ReasoningNode implements NodeAction {
|
|||||||
return false;
|
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
|
* 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
|
||||||
@ -857,6 +964,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
|
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
|
||||||
.activeCallbacks()
|
.activeCallbacks()
|
||||||
: toolCallbacks;
|
: toolCallbacks;
|
||||||
|
activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks);
|
||||||
|
|
||||||
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
|
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
|
||||||
|
|
||||||
@ -1142,6 +1250,34 @@ public class ReasoningNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.hasToolCalls()) {
|
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): {}",
|
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
|
||||||
result.toolCalls().size(),
|
result.toolCalls().size(),
|
||||||
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
|
||||||
@ -1219,12 +1355,43 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
|
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(
|
pushPhase(conversationId, "drafting_answer", Map.of(
|
||||||
"iteration", accessor.iterationCount(),
|
"iteration", accessor.iterationCount(),
|
||||||
"answerChars", content != null ? content.length() : 0
|
"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()
|
String answerWithSources = accessor.sourceEvidenceLedger()
|
||||||
.appendWikiSourceTable(content != null ? content : "");
|
.appendWikiSourceTable(accumulatedContent);
|
||||||
SourceEvidenceLedger.Validation validation =
|
SourceEvidenceLedger.Validation validation =
|
||||||
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
accessor.sourceEvidenceLedger().validateAnswer(answerWithSources);
|
||||||
boolean evidenceInsufficient = !validation.valid();
|
boolean evidenceInsufficient = !validation.valid();
|
||||||
@ -1251,9 +1418,9 @@ public class ReasoningNode implements NodeAction {
|
|||||||
.finalThinking(result.thinking())
|
.finalThinking(result.thinking())
|
||||||
.messages(List.of((Message) result.assistantMessage()))
|
.messages(List.of((Message) result.assistantMessage()))
|
||||||
.currentPhase("reasoning")
|
.currentPhase("reasoning")
|
||||||
.streamedContent(evidenceInsufficient ? (content != null ? content : "") : "")
|
.streamedContent(evidenceInsufficient ? accumulatedContent : "")
|
||||||
.finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL)
|
.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())
|
.thinkingStreamed(!result.thinking().isEmpty())
|
||||||
.llmCallCount(nextLlmCallCount)
|
.llmCallCount(nextLlmCallCount)
|
||||||
.mergeUsage(state, result)
|
.mergeUsage(state, result)
|
||||||
|
|||||||
@ -119,6 +119,10 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(FINAL_ANSWER_DRAFT, "");
|
return state.value(FINAL_ANSWER_DRAFT, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String longFormDraft() {
|
||||||
|
return state.value(LONG_FORM_DRAFT, "");
|
||||||
|
}
|
||||||
|
|
||||||
public boolean limitExceeded() {
|
public boolean limitExceeded() {
|
||||||
return state.value(LIMIT_EXCEEDED, false);
|
return state.value(LIMIT_EXCEEDED, false);
|
||||||
}
|
}
|
||||||
@ -453,6 +457,10 @@ public final class MateClawStateAccessor {
|
|||||||
return put(FINAL_ANSWER_DRAFT, draft);
|
return put(FINAL_ANSWER_DRAFT, draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputBuilder longFormDraft(String draft) {
|
||||||
|
return put(LONG_FORM_DRAFT, draft);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 终止 ----
|
// ---- 终止 ----
|
||||||
public OutputBuilder finalAnswer(String answer) {
|
public OutputBuilder finalAnswer(String answer) {
|
||||||
return put(FINAL_ANSWER, answer);
|
return put(FINAL_ANSWER, answer);
|
||||||
|
|||||||
@ -64,6 +64,8 @@ public final class MateClawStateKeys {
|
|||||||
|
|
||||||
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
|
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
|
||||||
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft";
|
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 阶段 */
|
/** 是否需要进入 summarizing 阶段 */
|
||||||
public static final String SHOULD_SUMMARIZE = "should_summarize";
|
public static final String SHOULD_SUMMARIZE = "should_summarize";
|
||||||
|
|||||||
@ -166,4 +166,27 @@ class StateGraphReActAgentStreamedContentDeltaTest {
|
|||||||
assertEquals(1, deltas.size());
|
assertEquals(1, deltas.size());
|
||||||
assertFalse(deltas.get(0).isEvent());
|
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));
|
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
|
@Test
|
||||||
@DisplayName("action-required text-only candidate requests one reasoning continuation")
|
@DisplayName("action-required text-only candidate requests one reasoning continuation")
|
||||||
void actionRequiredTextOnly_continuesOnce() throws Exception {
|
void actionRequiredTextOnly_continuesOnce() throws Exception {
|
||||||
@ -141,6 +163,134 @@ class ReasoningNodeOutputTest {
|
|||||||
assertTrue(((String) output.get(FINAL_ANSWER)).contains("未观察到实际"));
|
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
|
@Test
|
||||||
@DisplayName("failed action receipt overrides a model success claim")
|
@DisplayName("failed action receipt overrides a model success claim")
|
||||||
void failedActionReceipt_blocksSuccessClaim() throws Exception {
|
void failedActionReceipt_blocksSuccessClaim() throws Exception {
|
||||||
|
|||||||
@ -317,7 +317,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('agents.fields.maxIterations') }}</label>
|
<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>
|
</div>
|
||||||
<!-- RFC-03 Lane G1: per-Agent model override. Empty value falls
|
<!-- RFC-03 Lane G1: per-Agent model override. Empty value falls
|
||||||
back to the global default in ModelConfigService.resolveModel. -->
|
back to the global default in ModelConfigService.resolveModel. -->
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user