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 c024cbb2..15be0dc1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -240,14 +240,24 @@ public class AgentGraphBuilder { // 内置搜索作为首选,search 工具作为补充/兜底 log.info("内置搜索已开启 (provider={}),search 工具保留作为补充通道", provider.getProviderId()); } - // Default 100 if DB row leaves max_iterations null; clamp per-agent overrides - // to the hard ceiling (BaseAgent.MAX_ITERATIONS_HARD_CEILING) so a misconfigured - // row can never push an unbounded loop. Effective range: 1..100. + // Default 100 if DB row leaves max_iterations null. Negative or zero is an + // explicit opt-in to "no soft cap" — ObservationDispatcher already treats + // maxIterations<=0 as "do not enforce", so the agent runs until the LLM + // emits a final answer (or returnDirect short-circuits). Positive values + // are clamped to the hard ceiling so a misconfigured row can't skip the + // safety net unintentionally. int rawMaxIter = entity.getMaxIterations() != null ? entity.getMaxIterations() : 100; - int maxIter = Math.max(1, Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING)); - if (maxIter != rawMaxIter) { - log.warn("Agent {} max_iterations={} clamped to {} (1..{})", - entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING); + int maxIter; + if (rawMaxIter <= 0) { + maxIter = 0; + log.info("Agent {} max_iterations={} → unlimited soft cap (LLM controls termination)", + entity.getId(), rawMaxIter); + } else { + maxIter = Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING); + if (maxIter != rawMaxIter) { + log.warn("Agent {} max_iterations={} clamped to {} (1..{})", + entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING); + } } String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, @@ -474,13 +484,37 @@ public class AgentGraphBuilder { .addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END); return graph.compile(CompileConfig.builder() - .recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300) + .recursionLimit(frameworkRecursionLimit()) .build()); } catch (Exception e) { throw new MateClawException("err.agent.plan_compile_failed", "Plan-Execute StateGraph 编译失败: " + e.getMessage()); } } + /** + * Hard ceiling for the underlying graph framework's recursion guard. + *

+ * The framework treats "recursion limit reached" as a normal completion — + * it emits a {@code done} signal with no exception and no log. That makes + * it indistinguishable from a real final answer downstream, and is the + * mechanism by which a turn can silently stop mid-execution and persist + * only whatever partial content the accumulator happened to hold. + *

+ * To avoid that class of bug, the recursion limit must be sized so it can + * never trip before the soft cap (ObservationDispatcher → + * LimitExceededNode), which is the only path that produces a proper + * {@code finish_reason} and human-facing message. Sized for the maximum + * effective soft cap (DB hard ceiling + thinking-mode bonus) multiplied + * by 4 (each iteration is worst-case reasoning + summarizing + action + + * observation) plus a 100-step buffer for phase nodes, approval replays + * and tool-result chunking. Decoupled from the per-agent value so a small + * {@code max_iterations} can never accidentally re-introduce the silent + * killer. + */ + private static int frameworkRecursionLimit() { + return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100; + } + CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) { return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null); } @@ -636,7 +670,7 @@ public class AgentGraphBuilder { .addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END); return graph.compile(CompileConfig.builder() - .recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300) + .recursionLimit(frameworkRecursionLimit()) .withLifecycleListener(new ReActLifecycleListener()) .build()); } catch (Exception e) { 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 8afd7ae3..aa040600 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 @@ -193,6 +193,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false); AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false); AtomicReference lastEmittedStreamedContent = new AtomicReference<>(""); + // Silent-termination guard (mirrors chatStructuredStream) + AtomicInteger lastIteration = new AtomicInteger(0); + AtomicInteger lastSoftCap = new AtomicInteger(0); + AtomicBoolean sawLegitimateExit = new AtomicBoolean(false); return compiledGraph.stream(inputs, config) .flatMapIterable(output -> { @@ -239,6 +243,14 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); + lastIteration.set(output.state().value(CURRENT_ITERATION, 0)); + lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0)); + if (hasFinalAnswer(output) + || Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false)) + || !output.state().value(FINISH_REASON).orElse("").isBlank()) { + sawLegitimateExit.set(true); + } + return deltas; }) .concatWith(Mono.fromSupplier(() -> { @@ -252,7 +264,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC } return null; }).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())) - .doOnComplete(() -> setState(AgentState.IDLE)) + .doOnComplete(() -> { + setState(AgentState.IDLE); + if (!sawLegitimateExit.get()) { + log.error("[{}] StateGraph replay stream completed WITHOUT a final answer / " + + "limit_exceeded / finish_reason — likely framework-level silent " + + "termination. conversationId={}, lastIteration={}, softCap={}", + agentName, conversationId, lastIteration.get(), lastSoftCap.get()); + } + }) .doOnError(e -> { log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage()); setState(AgentState.ERROR); @@ -294,6 +314,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC // STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写), // 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。 AtomicReference lastEmittedStreamedContent = new AtomicReference<>(""); + // Silent-termination guardrail: track the highest iteration / soft cap + // observed and whether the graph reached a legitimate exit (final answer + // or limit-exceeded node). If the framework completes the Flux without + // either signal we log.error in doOnComplete — the graph framework + // historically treated its own recursion cap as a silent normal + // completion, which masked turns ending mid-execution. Decoupling the + // recursionLimit at compile time should keep this from firing, but the + // guard catches any future regression instead of letting it ship silent. + AtomicInteger lastIteration = new AtomicInteger(0); + AtomicInteger lastSoftCap = new AtomicInteger(0); + AtomicBoolean sawLegitimateExit = new AtomicBoolean(false); return compiledGraph.stream(inputs, config) .flatMapIterable(output -> { @@ -349,6 +380,15 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); + // 4. Silent-termination guard inputs + lastIteration.set(output.state().value(CURRENT_ITERATION, 0)); + lastSoftCap.set(output.state().value(MAX_ITERATIONS, 0)); + if (hasFinalAnswer(output) + || Boolean.TRUE.equals(output.state().value(LIMIT_EXCEEDED, false)) + || !output.state().value(FINISH_REASON).orElse("").isBlank()) { + sawLegitimateExit.set(true); + } + return deltas; }) // 流正常完成后追加内部 usage 事件 @@ -363,7 +403,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC } return null; }).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty())) - .doOnComplete(() -> setState(AgentState.IDLE)) + .doOnComplete(() -> { + setState(AgentState.IDLE); + if (!sawLegitimateExit.get()) { + log.error("[{}] StateGraph structured stream completed WITHOUT a final answer / " + + "limit_exceeded / finish_reason — likely framework-level silent " + + "termination (recursionLimit reached or upstream truncation). " + + "conversationId={}, lastIteration={}, softCap={}", + agentName, conversationId, lastIteration.get(), lastSoftCap.get()); + } + }) .doOnError(e -> { log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage()); setState(AgentState.ERROR); @@ -406,9 +455,13 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。"); inputs.put(MESSAGES, messages); // 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用) + // maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路, + // 否则 thinking-on 会把"无限"误算成 5(变成"5 步就停")。 String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get(); boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel); - int effectiveMaxIterations = thinkingOn ? maxIterations + 5 : maxIterations; + int effectiveMaxIterations = (maxIterations <= 0) + ? 0 + : (thinkingOn ? maxIterations + 5 : maxIterations); inputs.put(MAX_ITERATIONS, effectiveMaxIterations); inputs.put(CURRENT_ITERATION, 0); // 初始化新字段