mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): decouple framework recursion limit from per-agent max_iterations
This commit is contained in:
parent
c1c2525d0e
commit
80cb3c84eb
@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* To avoid that class of bug, the recursion limit must be sized so it can
|
||||
* <em>never</em> 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) {
|
||||
|
||||
@ -193,6 +193,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||
AtomicReference<String> 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().<String>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<String> 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().<String>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);
|
||||
// 初始化新字段
|
||||
|
||||
Loading…
Reference in New Issue
Block a user