fix(agent): complete long-form responses reliably

This commit is contained in:
matevip 2026-08-24 04:16:57 -04:00
parent e88be95cd2
commit 987bc2001a
8 changed files with 389 additions and 12 deletions

View File

@ -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)

View File

@ -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;

View File

@ -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)

View File

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

View File

@ -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";

View File

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

View File

@ -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 {

View File

@ -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. -->