feat(agent): loop-engineering robustness — goal continuation, plan re-plan, stall detection

- goal: continue (not skip) on max-iterations and evidence-insufficient turns.
  A max-iterations turn grants a fresh iteration budget ("hard continuation"),
  bounded per run and sized into the graph recursion ceiling, so a task too big
  for one budget keeps going instead of stalling until the next user message.
- plan-execute: re-plan the remaining work on a step exception, and on a
  signature-based stall (repeated failures / identical results / no usable
  result) instead of advancing dependent steps with junk; bounded by a per-run
  re-plan cap, with a graduated change-strategy nudge before the hard stop.
- plan-execute: auto-derive a goal from a genuine multi-step plan, seeding the
  acceptance criteria from the plan steps, so the goal subsystem engages without
  the model calling setGoal; broadcast goal_created so the UI hydrates.
- react: refund the iteration for setup-only rounds (load_skill / enable_tool)
  so a tight budget is not eaten by the load-then-use two-step.
- ui: re-fetch the active goal when a turn finishes so a goal created or mutated
  mid-conversation surfaces without depending on an SSE event.
- streaming: make retry backoff / total-time budget instance fields with a
  test-only seam; clarify that the wall-clock budget (not max-retries) bounds a
  sustained SERVER_ERROR loop to ~8 attempts, fixing the slow/flaky retry test.
This commit is contained in:
matevip 2026-06-17 06:37:08 +08:00
parent 85ceafa055
commit 1affbd7b82
22 changed files with 1437 additions and 37 deletions

View File

@ -554,7 +554,7 @@ public class AgentGraphBuilder {
if (auditEventService != null) {
executor.setAuditEventService(auditEventService);
}
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
@ -579,6 +579,7 @@ public class AgentGraphBuilder {
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
.addStrategy(PlanStateKeys.PLAN_REPLAN_COUNT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
// 工作上下文REPLACE 策略每次重新生成
@ -643,6 +644,7 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
@ -657,6 +659,7 @@ public class AgentGraphBuilder {
// DIRECT_ANSWER_NODE END
// STEP_EXECUTION (StepProgressDispatcher)
// STEP_EXECUTION (loop)
// PLAN_GENERATION (re-plan on step failure, bounded by PLAN_REPLAN_COUNT)
// PLAN_SUMMARY (active goal?)
// GOAL_EVALUATION (followup?)
// PLAN_GENERATION (re-plan)
@ -690,11 +693,18 @@ public class AgentGraphBuilder {
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
// Step-failure recovery: re-plan the remaining work
// (StepProgressDispatcher returns this on phase=plan_replan).
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
boolean hasGoal = a.hasActiveGoal();
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
@ -719,7 +729,11 @@ public class AgentGraphBuilder {
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
boolean hasGoal = a.hasActiveGoal();
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
@ -756,9 +770,17 @@ public class AgentGraphBuilder {
* and tool-result chunking. Decoupled from the per-agent value so a small
* {@code max_iterations} can never accidentally re-introduce the silent
* killer.
* <p>
* The base segment budget is further multiplied to cover goal-driven "hard
* continuations" — each grants a fresh full iteration budget after a
* max-iterations turn (see {@code GoalEvaluationNode}). One run can perform
* up to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING} of them, so
* the ceiling is sized for {@code (1 + CEILING)} segments to keep the
* recursion guard from tripping before the soft caps do.
*/
private static int frameworkRecursionLimit() {
return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100;
int perSegment = (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4;
return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
@ -826,6 +848,7 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
// 迭代控制
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ITERATION_REFUND_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
// 工具调用
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
@ -907,6 +930,7 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
@ -956,7 +980,11 @@ public class AgentGraphBuilder {
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
boolean hasGoal = a.hasActiveGoal();
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE

View File

@ -362,9 +362,42 @@ public class NodeStreamingChatHelper {
// Hard time budget for the primary retry loop (3 min). Prevents
// retries from stalling a single conversation turn indefinitely.
// Aligned with WikiProcessingService.llmMaxTotalDurationMs.
private static final long MAX_TOTAL_DURATION_MS = 3 * 60 * 1000L;
private static final long BACKOFF_BASE_MS = 3000;
private static final long BACKOFF_CAP_MS = 60_000;
//
// Because the backoff grows exponentially (3s, 6s, 12s, 24s, 48s, then
// capped at 60s), this wall-clock budget not MAX_RETRIES is what
// actually bounds a sustained SERVER_ERROR loop: only ~8 of the 10
// retries fit inside 3 minutes before the elapsed-time check in
// streamCallInternal breaks to the fallback chain.
//
// These three values are instance fields seeded from the DEFAULT_*
// constants (rather than compile-time constants) so tests can shrink
// them to exercise the full retry path in milliseconds instead of
// minutes. Production wiring never overrides them see
// setRetryTimingForTest.
private static final long DEFAULT_MAX_TOTAL_DURATION_MS = 3 * 60 * 1000L;
private static final long DEFAULT_BACKOFF_BASE_MS = 3000;
private static final long DEFAULT_BACKOFF_CAP_MS = 60_000;
private long maxTotalDurationMs = DEFAULT_MAX_TOTAL_DURATION_MS;
private long backoffBaseMs = DEFAULT_BACKOFF_BASE_MS;
private long backoffCapMs = DEFAULT_BACKOFF_CAP_MS;
/**
* Test-only seam to shrink the retry backoff and total-time budget so the
* full {@link #MAX_RETRIES} path (or the time-budget cut-off) can be
* exercised in milliseconds instead of minutes. Package-private and never
* invoked from production wiring, which always keeps the {@code DEFAULT_*}
* timings.
*
* @param backoffBaseMs base backoff for the first retry (doubles each attempt)
* @param backoffCapMs per-attempt backoff ceiling
* @param maxTotalDurationMs hard wall-clock budget for the whole primary retry loop
*/
void setRetryTimingForTest(long backoffBaseMs, long backoffCapMs, long maxTotalDurationMs) {
this.backoffBaseMs = backoffBaseMs;
this.backoffCapMs = backoffCapMs;
this.maxTotalDurationMs = maxTotalDurationMs;
}
private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
@ -592,7 +625,7 @@ public class NodeStreamingChatHelper {
// conversation turn indefinitely (e.g., a provider that stays
// at 503 for minutes). Aligned with Wiki's maxTotalDurationMs.
long elapsedMs = System.currentTimeMillis() - callStartMs;
if (elapsedMs >= MAX_TOTAL_DURATION_MS) {
if (elapsedMs >= maxTotalDurationMs) {
log.warn("[{}] Primary retry time budget exhausted ({}ms), handing off to fallback chain",
phase, elapsedMs);
break;
@ -875,10 +908,10 @@ public class NodeStreamingChatHelper {
String conversationId, String phase,
boolean broadcast, int attempt) {
if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, BACKOFF_CAP_MS);
delay = Math.min(delay, backoffCapMs);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
phase, attempt, MAX_RETRIES, delay, conversationId);
// 广播给前端用户可见的重试倒计时

View File

@ -78,7 +78,12 @@ public class GoalEvaluationNode implements NodeAction {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
Optional<Object> goalOpt = accessor.activeGoal();
// Resolve the active goal from the turn-start snapshot, falling back to
// a conversation lookup. The fallback is what makes a goal created
// MID-TURN (the agent calls setGoal, which only writes the DB) get
// evaluated on the very turn it was set otherwise ACTIVE_GOAL is empty
// in this run's state and the goal would sit inert until the next message.
Optional<GoalEntity> goalOpt = resolveActiveGoal(state, goalService);
if (goalOpt.isEmpty()) {
return Map.of();
}
@ -94,26 +99,32 @@ public class GoalEvaluationNode implements NodeAction {
// chat composable's `message_complete` handler optimistically sets
// evaluating=true; without a balancing event the ring would stay
// in that state forever after e.g. a max-iterations turn.
Long goalIdForEvents = (goalOpt.get() instanceof GoalEntity ge) ? ge.getId() : null;
Long goalIdForEvents = goalOpt.get().getId();
// ReAct path: FinalAnswerNode wrote a canonical finishReason that
// determines whether this turn counts. Plan-Execute usually doesn't
// set finishReason on the happy path, so we only enforce these
// exit conditions in REACT mode + the universal awaiting_approval
// gate that both flavors share.
// A turn that hit the ReAct iteration cap. Continuing it needs a FRESH
// iteration budget (a "hard continuation"), handled in the follow-up
// branch below; capture it here while finishReason is still authoritative.
boolean reactIterationCapReached = flavor == GraphFlavor.REACT
&& isIterationCapReached(accessor.finishReason());
if (flavor == GraphFlavor.REACT) {
String fr = accessor.finishReason();
if (FinishReason.EVIDENCE_INSUFFICIENT.getValue().equals(fr)
|| FinishReason.STOPPED.getValue().equals(fr)
|| FinishReason.ERROR_FALLBACK.getValue().equals(fr)
|| FinishReason.RETURN_DIRECT.getValue().equals(fr)
|| FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(fr)) {
if (isHardSkipFinishReason(fr)) {
log.debug("[GoalEvaluationNode] skipping evaluation (REACT finishReason={})", fr);
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(goalIdForEvents, "react_finish_reason:" + fr)))
.build();
}
// MAX_ITERATIONS_REACHED and EVIDENCE_INSUFFICIENT intentionally
// fall through: both mean "answer produced but the goal is likely
// unmet", which is exactly when a corrective follow-up helps.
// Max-iterations additionally needs a fresh budget (see below).
}
if (accessor.awaitingApproval()) {
return MateClawStateAccessor.output()
@ -122,14 +133,7 @@ public class GoalEvaluationNode implements NodeAction {
.build();
}
Object goalObj = goalOpt.get();
if (!(goalObj instanceof GoalEntity goal)) {
log.warn("[GoalEvaluationNode] ACTIVE_GOAL is not a GoalEntity: {}", goalObj.getClass());
return MateClawStateAccessor.output()
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(null, "non_goal_entity")))
.build();
}
GoalEntity goal = goalOpt.get();
String terminal = accessor.terminalAnswer();
if (terminal.isEmpty()) {
@ -226,6 +230,9 @@ public class GoalEvaluationNode implements NodeAction {
}
int followupCountThisRun = accessor.goalFollowupCount();
int hardContinuationCount = accessor.goalHardContinuationCount();
int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(),
GoalProperties.MAX_HARD_CONTINUATIONS_CEILING);
Optional<String> followup;
try {
followup = followupService.maybeBuildFollowup(refreshed, result);
@ -241,11 +248,19 @@ public class GoalEvaluationNode implements NodeAction {
// active and the cross-message turn / LLM budget (or the user) carries
// it on.
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
if (followup.isPresent() && perRunCapReached) {
log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run",
followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId());
// A max-iterations continuation re-runs a FULL fresh ReAct segment
// (iteration budget reset), so it carries a tighter, dedicated cap on
// top of the per-run follow-up cap and is sized into the graph
// recursion ceiling. hardCap==0 keeps the legacy behaviour (a
// max-iterations turn simply ends the run).
boolean hardCapReached = reactIterationCapReached && hardContinuationCount >= hardCap;
if (followup.isPresent() && (perRunCapReached || hardCapReached)) {
log.info("[GoalEvaluationNode] follow-up suppressed for goal={} " +
"(followups {}/{}, hardContinuations {}/{}, iterationCapReached={}); ending this run",
refreshed.getId(), followupCountThisRun, properties.getMaxFollowupsPerRun(),
hardContinuationCount, hardCap, reactIterationCapReached);
}
if (followup.isPresent() && !perRunCapReached) {
if (followup.isPresent() && !perRunCapReached && !hardCapReached) {
try {
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
} catch (Throwable t) {
@ -283,6 +298,20 @@ public class GoalEvaluationNode implements NodeAction {
out.clearFinalAnswer()
.clearFinishReason()
.messages(List.of((Message) new UserMessage(followup.get())));
if (reactIterationCapReached) {
// Hard continuation: the run's iteration budget is spent, so
// grant a brand-new ReAct segment. Reset the counter, clear
// the stale limit-exceeded draft/flag (FinalAnswerNode prefers
// the draft over a freshly reasoned answer) and any latched
// error, and advance the dedicated hard-continuation counter.
out.iterationCount(0)
.clearLimitExceededDraft()
.error("")
.goalHardContinuationCount(hardContinuationCount + 1);
log.info("[GoalEvaluationNode] hard continuation {}/{} for goal={} " +
"(fresh ReAct iteration budget after max-iterations turn)",
hardContinuationCount + 1, hardCap, refreshed.getId());
}
} else {
// Plan-Execute: wipe the wider mid-pass + terminal state.
// WORKING_CONTEXT and PlanStateKeys.GOAL are intentionally
@ -318,6 +347,70 @@ public class GoalEvaluationNode implements NodeAction {
.build();
}
/**
* Resolve the active goal for this run: prefer the turn-start
* {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation
* lookup so a goal created mid-turn (via {@code setGoal}, which only writes
* the DB) is still evaluated on the turn it was set.
*
* <p>Shared by {@link #apply} and the {@code FinalAnswer/PlanSummary
* GoalEvaluation} routing edges so both agree on whether a goal is active.
* The DB fallback costs one indexed lookup per terminal turn whose snapshot
* is empty; callers should additionally gate on {@code properties.isEnabled()}
* to skip it when the feature is off.
*/
public static Optional<GoalEntity> resolveActiveGoal(OverAllState state, GoalService goalService) {
MateClawStateAccessor a = new MateClawStateAccessor(state);
Optional<Object> snapshot = a.activeGoal();
if (snapshot.isPresent() && snapshot.get() instanceof GoalEntity ge) {
return Optional.of(ge);
}
if (goalService == null) {
return Optional.empty();
}
String conversationId = a.conversationId();
if (conversationId == null || conversationId.isBlank()) {
return Optional.empty();
}
try {
return Optional.ofNullable(goalService.findActiveByConversation(conversationId));
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] active-goal fallback lookup failed for conversation={}: {}",
conversationId, t.toString());
return Optional.empty();
}
}
/**
* REACT-mode finish reasons that should neither count toward the goal nor
* trigger a continuation:
* <ul>
* <li>{@code STOPPED} the user halted the run; don't fight them.</li>
* <li>{@code RETURN_DIRECT} a tool produced the answer verbatim; this
* is not goal-progress reasoning work to evaluate or continue.</li>
* <li>{@code ERROR_FALLBACK} a fatal error already failed the turn;
* re-running immediately would just re-fail.</li>
* </ul>
* Other terminal reasons notably {@code MAX_ITERATIONS_REACHED} and
* {@code EVIDENCE_INSUFFICIENT} mean "answer produced but the goal is
* likely unmet", which is exactly when a corrective follow-up helps, so
* they are deliberately NOT skipped.
*/
static boolean isHardSkipFinishReason(String finishReason) {
return FinishReason.STOPPED.getValue().equals(finishReason)
|| FinishReason.RETURN_DIRECT.getValue().equals(finishReason)
|| FinishReason.ERROR_FALLBACK.getValue().equals(finishReason);
}
/**
* True when the terminal turn hit the ReAct iteration cap. Continuing such
* a turn requires a fresh iteration budget (a "hard continuation"), because
* the run's shared budget is already exhausted.
*/
static boolean isIterationCapReached(String finishReason) {
return FinishReason.MAX_ITERATIONS_REACHED.getValue().equals(finishReason);
}
/** Stand-in for a missing {@code GraphEventPublisher.custom()} factory. */
private static GraphEventPublisher.GraphEvent goalEvent(String type, Map<String, Object> data) {
return new GraphEventPublisher.GraphEvent(type, Map.copyOf(data), System.currentTimeMillis());

View File

@ -32,6 +32,18 @@ public class ObservationNode implements NodeAction {
private final ObservationProcessor observationProcessor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
/**
* Progressive-disclosure meta-tools that perform setup, not real work. A
* round whose entire batch is one of these is refunded its iteration (see
* {@link MateClawStateKeys#ITERATION_REFUND_COUNT}). Mirrors the authoritative
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
*/
private static final java.util.Set<String> DISCLOSURE_TOOLS =
java.util.Set.of("load_skill", "enable_tool");
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
public ObservationNode(ObservationProcessor observationProcessor) {
this(observationProcessor, null);
}
@ -56,14 +68,29 @@ public class ObservationNode implements NodeAction {
int currentIteration = accessor.iterationCount();
int maxIterations = accessor.maxIterations();
int nextIteration = currentIteration + 1;
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
// 提取最新的工具结果并处理
List<ToolResponseMessage.ToolResponse> toolResults =
state.<List<ToolResponseMessage.ToolResponse>>value(TOOL_RESULTS).orElse(List.of());
// Iteration refund: a round whose entire batch was progressive-disclosure
// setup (load_skill / enable_tool) did no real work, so don't charge it an
// iteration otherwise a tight budget loses a step to the load-then-use
// two-step. Bounded by MAX_ITERATION_REFUNDS_PER_RUN so a model that only
// ever loads skills can't dodge the budget forever.
int refundCount = accessor.iterationRefundCount();
boolean setupOnlyRound = !toolResults.isEmpty()
&& toolResults.stream().allMatch(tr -> DISCLOSURE_TOOLS.contains(tr.name()));
boolean refundIteration = setupOnlyRound && refundCount < MAX_ITERATION_REFUNDS_PER_RUN;
int nextIteration = refundIteration ? currentIteration : currentIteration + 1;
if (refundIteration) {
log.info("[ObservationNode] Iteration refunded (setup-only round, refunds {}/{}); staying at {}/{}",
refundCount + 1, MAX_ITERATION_REFUNDS_PER_RUN, nextIteration, maxIterations);
} else {
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
}
// 将每个工具结果通过 ObservationProcessor 标准化和截断
List<String> processedObservations = toolResults.stream()
.map(tr -> observationProcessor.process(tr.name(), tr.responseData()))
@ -121,6 +148,10 @@ public class ObservationNode implements NodeAction {
.shouldSummarize(shouldSummarize)
.toolCallCount(newToolCallCount);
if (refundIteration) {
builder.iterationRefundCount(refundCount + 1);
}
// Close out the iteration we just observed. We use currentIteration
// (not nextIteration) so the index pairs with whatever
// iteration_start the ReasoningNode emitted at the top of this turn.

View File

@ -30,6 +30,12 @@ public class StepProgressDispatcher implements EdgeAction {
if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
return StateGraph.END;
}
// Step-failure recovery: a failed step requested a re-plan of the
// remaining work. Route back to PlanGeneration instead of aborting;
// PLAN_REPLAN_COUNT (set by StepExecutionNode) bounds the loop.
if ("plan_replan".equals(currentPhase)) {
return PlanStateKeys.PLAN_GENERATION_NODE;
}
int currentIndex = state.value(PlanStateKeys.CURRENT_STEP_INDEX, 0);
List<String> steps = state.<List<String>>value(PlanStateKeys.PLAN_STEPS).orElse(List.of());

View File

@ -16,8 +16,14 @@ import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import vip.mate.planning.service.PlanningService;
import java.util.ArrayList;
@ -52,6 +58,14 @@ public class PlanGenerationNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final AgentToolSet toolSet;
/** Optional — auto-derive a goal from the plan. Null disables the feature (legacy/test). */
private final GoalService goalService;
private final GoalProperties goalProperties;
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
/** Cap the auto-derived goal title; the full request rides in the description. */
private static final int AUTO_GOAL_TITLE_MAX = 80;
/**
* Structured triage result field names use @JsonProperty to match the
@ -168,11 +182,21 @@ public class PlanGenerationNode implements NodeAction {
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
AgentToolSet toolSet) {
this(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, null, null);
}
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
AgentToolSet toolSet,
GoalService goalService, GoalProperties goalProperties) {
this.chatModel = chatModel;
this.planningService = planningService;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
this.toolSet = toolSet;
this.goalService = goalService;
this.goalProperties = goalProperties;
}
/**
@ -180,7 +204,65 @@ public class PlanGenerationNode implements NodeAction {
*/
@Deprecated
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
this(chatModel, planningService, null, null, null);
this(chatModel, planningService, null, null, null, null, null);
}
/**
* Auto-derive a goal from a freshly-generated multi-step plan so the
* Plan-Execute path engages the goal subsystem (the planner / step executor
* never call {@code setGoal} themselves). The plan steps become the goal's
* acceptance criteria the plan IS the decomposition so the first
* evaluation skips the bootstrap round and judges those criteria directly.
*
* <p>Returns the created goal (to inject into {@code ACTIVE_GOAL} so THIS
* run's GoalEvaluationNode picks it up) or {@code null} when not applicable:
* feature off, fewer than {@link #MIN_STEPS_FOR_AUTO_GOAL} steps, no channel
* context, or the conversation already has an active goal. Best-effort
* any failure is swallowed so planning is never blocked by goal bookkeeping.
*/
GoalEntity maybeAutoCreateGoal(PlanStateAccessor accessor, List<String> steps) {
if (goalService == null || goalProperties == null
|| !goalProperties.isEnabled() || !goalProperties.isAutoGoalFromPlan()) {
return null;
}
if (steps == null || steps.size() < MIN_STEPS_FOR_AUTO_GOAL) {
return null;
}
ChatOrigin origin = accessor.chatOrigin();
String convId = origin.conversationId();
if (convId == null || convId.isBlank() || origin.agentId() == null) {
return null;
}
try {
if (goalService.findActiveByConversation(convId) != null) {
return null; // respect an existing goal (incl. re-plan passes)
}
String request = stripInjectedContext(accessor.goal()).strip();
GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(convId);
req.setAgentId(origin.agentId());
req.setWorkspaceId(origin.workspaceId() != null ? origin.workspaceId() : 1L);
req.setTitle(request.isEmpty() ? "多步任务"
: request.length() > AUTO_GOAL_TITLE_MAX
? request.substring(0, AUTO_GOAL_TITLE_MAX) : request);
req.setDescription(request);
List<GoalCriterion> criteria = steps.stream()
.filter(s -> s != null && !s.isBlank())
.map(s -> new GoalCriterion("", s.strip(), false, ""))
.collect(Collectors.toList());
if (!criteria.isEmpty()) {
req.setCriteria(criteria);
}
String username = origin.requesterId() != null && !origin.requesterId().isBlank()
? origin.requesterId() : "system";
GoalEntity created = goalService.create(req, username);
log.info("[PlanGeneration] Auto-derived goal {} from plan ({} criteria) for conversation {}",
created.getId(), criteria.size(), convId);
return created;
} catch (Exception e) {
log.warn("[PlanGeneration] Auto-goal-from-plan skipped (non-fatal): {}", e.toString());
return null;
}
}
@Override
@ -375,7 +457,22 @@ public class PlanGenerationNode implements NodeAction {
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
return PlanStateAccessor.output()
// Auto-derive a goal from a genuine multi-step plan so the
// Plan-Execute path engages the goal subsystem. Injected into
// ACTIVE_GOAL so this same run's GoalEvaluationNode evaluates it.
GoalEntity autoGoal = maybeAutoCreateGoal(accessor, steps);
if (autoGoal != null && goalService != null) {
// Surface it to the UI exactly like the setGoal tool does
// ({goalId, conversationId, goal}) so the goal panel hydrates
// even though the user never called setGoal. Same SSE event the
// frontend goal store already listens for.
events.add(new GraphEventPublisher.GraphEvent("goal_created", Map.of(
"goalId", String.valueOf(autoGoal.getId()),
"conversationId", conversationId,
"goal", goalService.toResponse(autoGoal)), System.currentTimeMillis()));
}
PlanStateAccessor.OutputBuilder planOut = PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(steps)
@ -385,8 +482,11 @@ public class PlanGenerationNode implements NodeAction {
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
.events(events);
if (autoGoal != null) {
planOut.put(MateClawStateKeys.ACTIVE_GOAL, autoGoal);
}
return planOut.build();
} catch (Exception e) {
log.error("[PlanGeneration] Triage failed, falling back to single-step plan: {}", e.getMessage(), e);

View File

@ -89,6 +89,16 @@ public class StepExecutionNode implements NodeAction {
* pathological cases where the agent appears frozen to the user.
*/
private static final long STEP_WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000L;
/**
* Max re-plans per graph run. When a step throws, the executor re-plans the
* remaining work around the failure instead of aborting the whole plan a
* single transient tool error or one badly-scoped step no longer kills the
* task. Bounded so a step that fails every attempt can't re-plan forever;
* once exhausted the plan aborts as before. Kept small (the recursion
* ceiling already accommodates it) raise with care.
*/
private static final int MAX_REPLANS_PER_RUN = 1;
private static final ObjectMapper MAPPER = new ObjectMapper();
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
@ -222,6 +232,12 @@ public class StepExecutionNode implements NodeAction {
long stepStartedAtMs = System.currentTimeMillis();
boolean wallClockExceeded = false;
// Signature-based progress detector: nudges the model to change strategy
// when a round stalls (repeated failures / identical results), and flags
// the step as stuck past a hard threshold so we re-plan instead of
// burning the whole tool-call budget and advancing with junk.
StepProgressTracker progressTracker = new StepProgressTracker();
try {
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
long elapsedMs = System.currentTimeMillis() - stepStartedAtMs;
@ -353,6 +369,30 @@ public class StepExecutionNode implements NodeAction {
break;
}
// Progress tracking: feed this round's tool results to the
// detector. A WARN-level stall injects a one-shot "change
// strategy" SystemMessage the model sees on its next call; a
// HALT-level stall stops the inner loop so the post-loop logic
// re-plans instead of spinning to the tool-call ceiling.
java.util.Map<String, String> idToArgs = new java.util.HashMap<>();
for (AssistantMessage.ToolCall tc : allToolCalls) {
if (tc != null && tc.id() != null) {
idToArgs.put(tc.id(), tc.arguments());
}
}
for (ToolResponseMessage.ToolResponse tr : toolResponses) {
var nudge = progressTracker.record(
tr.name(), idToArgs.getOrDefault(tr.id(), ""), tr.responseData());
if (nudge.isPresent()) {
messages.add(new SystemMessage(nudge.get()));
}
}
if (progressTracker.isStuck()) {
log.warn("[StepExecution] Step {} stalled ({}); stopping inner loop to re-plan",
stepIndex, progressTracker.haltReason());
break;
}
// RFC-052: returnDirect short-circuit. Any direct tool in this
// step ends the plan immediately; the dispatcher routes via
// currentPhase=plan_aborted so no further LLM call happens.
@ -418,6 +458,56 @@ public class StepExecutionNode implements NodeAction {
.build();
}
// Step-failure recovery WITHOUT an exception: the inner loop ended
// with no usable result stalled (repeated failures / identical
// results, flagged by the progress tracker), hit the wall-clock or
// tool-call ceiling, or returned an empty answer. Re-plan the
// remaining work around it instead of advancing dependent steps with
// junk. Shares PLAN_REPLAN_COUNT with the exception path; once the
// budget is spent we fall through to the legacy "complete with a
// failure note" path below so the plan still terminates.
boolean noUsableResult = progressTracker.isStuck()
|| finalResult == null || finalResult.isBlank();
int noProgressReplanCount = accessor.replanCount();
if (noUsableResult && noProgressReplanCount < MAX_REPLANS_PER_RUN) {
String reason = progressTracker.isStuck()
? "本步骤陷入停滞(" + progressTracker.haltReason() + "),未取得有效结果"
: wallClockExceeded
? "本步骤超过最大耗时限制,未取得有效结果"
: finalResult == null
? "本步骤超过最大工具调用次数,未取得有效结果"
: "本步骤未产出有效结果";
planningService.updateSubPlanFailure(planId, stepIndex, reason);
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + "" + reason);
events.add(GraphEventPublisher.stepCompleted(stepIndex, reason));
if (iterationEventsOn) {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null, reason.length(), 0));
}
events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of(
"failedStepIndex", stepIndex,
"attempt", noProgressReplanCount + 1,
"maxReplans", MAX_REPLANS_PER_RUN,
"reason", reason), System.currentTimeMillis()));
log.warn("[StepExecution] Step {} produced no usable result ({}); re-planning (attempt {}/{})",
stepIndex + 1, reason, noProgressReplanCount + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.workingContext(buildReplanContext(accessor, stepIndex, reason))
.currentPhase("plan_replan")
.replanCount(noProgressReplanCount + 1)
.planId(null)
.planSteps(List.of())
.planValid(false)
.needsPlanning(true)
.currentStepIndex(0)
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
if (finalResult == null) {
if (wallClockExceeded) {
finalResult = "步骤执行超过最大耗时限制("
@ -438,6 +528,46 @@ public class StepExecutionNode implements NodeAction {
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
shortError != null ? shortError.length() : 0, 0));
}
// Step-failure recovery: rather than aborting the whole plan on a
// single failed step, re-plan the remaining work around the failure
// (up to MAX_REPLANS_PER_RUN). Completed steps are preserved in
// WORKING_CONTEXT, so the next PlanGeneration pass can skip them and
// route around (or retry) what broke. The mid-pass plan state is
// cleared so a fresh plan is derived; PLAN_REPLAN_COUNT bounds the loop.
int replanCount = accessor.replanCount();
if (replanCount < MAX_REPLANS_PER_RUN) {
String replanContext = buildReplanContext(accessor, stepIndex, shortError);
events.add(new GraphEventPublisher.GraphEvent("plan_replan", Map.of(
"failedStepIndex", stepIndex,
"attempt", replanCount + 1,
"maxReplans", MAX_REPLANS_PER_RUN,
"error", shortError == null ? "" : shortError),
System.currentTimeMillis()));
log.warn("[StepExecution] Step {} failed; re-planning remaining work (attempt {}/{})",
stepIndex + 1, replanCount + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.workingContext(replanContext)
.currentPhase("plan_replan")
.replanCount(replanCount + 1)
// Wipe mid-pass plan state so PlanGenerationNode re-derives
// a fresh plan from goal + (failure-augmented) context.
.planId(null)
.planSteps(List.of())
.planValid(false)
.needsPlanning(true)
.currentStepIndex(0)
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
log.warn("[StepExecution] Step {} failed and re-plan budget exhausted ({}); aborting plan",
stepIndex + 1, MAX_REPLANS_PER_RUN);
return PlanStateAccessor.output()
.currentStepResult(shortError)
.currentPhase("plan_aborted")
@ -612,6 +742,30 @@ public class StepExecutionNode implements NodeAction {
}
}
/**
* Augment the working context with a note about the failed step so the next
* PlanGeneration pass re-plans around it. The completed-step results are
* already encoded in {@code WORKING_CONTEXT}; this appends only the failure
* so the planner can skip what's done, retry differently, or route around
* the broken step. The note is an internal LLM prompt (Chinese, matching the
* surrounding planning/execution prompts).
*/
static String buildReplanContext(PlanStateAccessor accessor, int failedStepIndex, String error) {
List<String> steps = accessor.planSteps();
String failedTitle = (failedStepIndex >= 0 && failedStepIndex < steps.size())
? steps.get(failedStepIndex) : ("步骤 " + (failedStepIndex + 1));
StringBuilder sb = new StringBuilder(accessor.workingContext());
if (sb.length() > 0) {
sb.append("\n\n");
}
sb.append("【上一轮计划执行失败】步骤 ").append(failedStepIndex + 1)
.append("").append(failedTitle).append(")执行失败:")
.append(error == null ? "未知错误" : error)
.append("\n请基于上面已完成的工作重新规划达成总目标所需的剩余步骤")
.append("绕开或换一种方式完成失败的部分,不要重复已经完成的步骤。");
return sb.toString();
}
/**
* 将异常转换为简短的错误摘要避免将完整异常体尤其是 429 JSON写入后续 prompt
* <ul>

View File

@ -0,0 +1,152 @@
package vip.mate.agent.graph.plan.node;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Per-step progress detector for the Plan-Execute executor.
*
* <p>A plan step runs its own inner tool-calling loop. Without progress
* tracking, a step can spin repeatedly calling the same tool, or hammering
* different variants that all fail / return nothing until it hits the
* tool-call ceiling, then "complete" with an empty result and let the plan
* plow into dependent steps that have no real input.
*
* <p>This tracker watches the tool results of each round and recognises two
* signature-based stall patterns:
* <ul>
* <li><b>repeated failure</b> the same call (tool name + canonical args)
* keeps failing, or the same tool keeps failing with different args;</li>
* <li><b>no progress</b> a call keeps returning the <em>same</em> result,
* so re-issuing it yields nothing new.</li>
* </ul>
*
* <p>Detection is graduated: at the WARN threshold it emits a one-shot nudge
* (injected back into the step's messages so the model changes strategy);
* past the HALT threshold it flags the step as stuck so the executor can stop
* the inner loop and re-plan instead of advancing with junk. Thresholds are
* deliberately low the goal is to break a stall early, before the whole
* tool-call budget is burned.
*
* <p>Not thread-safe; create one per step.
*/
public final class StepProgressTracker {
/** Same exact call (tool + args) failing: nudge / halt thresholds. */
static final int SAME_CALL_FAIL_WARN = 2;
static final int SAME_CALL_FAIL_HALT = 4;
/** Same tool failing across different args: nudge / halt thresholds. */
static final int SAME_TOOL_FAIL_WARN = 3;
static final int SAME_TOOL_FAIL_HALT = 6;
/** Same call returning identical output (no new information): nudge / halt. */
static final int NO_PROGRESS_WARN = 2;
static final int NO_PROGRESS_HALT = 4;
/**
* Lower-cased markers that identify a tool result as a failure / empty
* outcome. Kept intentionally small and language-mixed: tool errors in this
* codebase surface as English exception text, while a few common "not
* found" phrasings also appear in Chinese tool output.
*/
private static final String[] FAILURE_MARKERS = {
"execution failed", "error:", "exception", "timeout", "timed out",
"enoent", "no such file", "not found", "authentication failed",
"permission denied", "failed to", "未找到", "不存在", "没有找到", "执行失败", "无法"
};
private final Map<String, Integer> sameCallFail = new HashMap<>();
private final Map<String, Integer> sameToolFail = new HashMap<>();
private final Map<String, Integer> resultRepeat = new HashMap<>();
private final Set<String> warnedKeys = new HashSet<>();
private boolean stuck = false;
private String haltReason = null;
/**
* Record one tool result from the current round.
*
* @param toolName the invoked tool's name (never null)
* @param argsJson the raw arguments JSON (may be empty when unresolved)
* @param resultText the tool's result text (may be null/empty)
* @return a nudge to inject into the step's messages when a WARN threshold
* was freshly crossed, otherwise empty. Each distinct warning fires
* at most once.
*/
public Optional<String> record(String toolName, String argsJson, String resultText) {
String name = toolName == null ? "tool" : toolName;
String args = argsJson == null ? "" : argsJson;
String result = resultText == null ? "" : resultText;
boolean failure = looksLikeFailure(result);
String callSig = name + "::" + args.hashCode();
String resultKey = callSig + "##" + result.trim().hashCode();
// No-progress: identical result for the same call, regardless of success.
int repeats = resultRepeat.merge(resultKey, 1, Integer::sum);
if (repeats >= NO_PROGRESS_HALT) {
markStuck("no_progress:" + name);
}
Optional<String> nudge = maybeWarn(repeats >= NO_PROGRESS_WARN, "np:" + resultKey,
"工具 " + name + " 已连续 " + repeats + " 次返回相同结果。不要重复同样的调用——"
+ "改用已有结果、换查询/换工具,或直接基于现有信息给出本步骤结论。");
if (failure) {
int callFails = sameCallFail.merge(callSig, 1, Integer::sum);
int toolFails = sameToolFail.merge(name, 1, Integer::sum);
if (callFails >= SAME_CALL_FAIL_HALT || toolFails >= SAME_TOOL_FAIL_HALT) {
markStuck("repeated_failure:" + name);
}
if (nudge.isEmpty()) {
nudge = maybeWarn(callFails >= SAME_CALL_FAIL_WARN, "cf:" + callSig,
"工具 " + name + " 用相同参数已失败 " + callFails + " 次,像是死循环。"
+ "先看错误原因再换一种方式,不要原样重试。");
}
if (nudge.isEmpty()) {
nudge = maybeWarn(toolFails >= SAME_TOOL_FAIL_WARN, "tf:" + name,
"工具 " + name + " 本步骤已失败 " + toolFails + " 次。停止在同一条失败路径上重试,"
+ "换工具或换思路完成本步骤。");
}
}
return nudge;
}
/** True once a HALT threshold was crossed — the step should stop and re-plan. */
public boolean isStuck() {
return stuck;
}
/** Machine-readable reason for the halt, or null when not stuck. */
public String haltReason() {
return haltReason;
}
private Optional<String> maybeWarn(boolean crossed, String key, String message) {
if (crossed && warnedKeys.add(key)) {
return Optional.of(message);
}
return Optional.empty();
}
private void markStuck(String reason) {
if (!stuck) {
stuck = true;
haltReason = reason;
}
}
static boolean looksLikeFailure(String result) {
if (result == null || result.isBlank()) {
return true; // an empty result is no progress either
}
String lower = result.toLowerCase();
for (String marker : FAILURE_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return false;
}
}

View File

@ -73,6 +73,11 @@ public final class PlanStateAccessor {
return state.<List<String>>value(COMPLETED_RESULTS).orElse(List.of());
}
/** Re-plans already performed this run (0 at run start). */
public int replanCount() {
return state.value(PLAN_REPLAN_COUNT, 0);
}
// ===== 终止 =====
public String finalSummary() {
@ -187,6 +192,10 @@ public final class PlanStateAccessor {
return put(CURRENT_STEP_INDEX, index);
}
public OutputBuilder replanCount(int count) {
return put(PLAN_REPLAN_COUNT, count);
}
public OutputBuilder currentStepTitle(String title) {
return put(CURRENT_STEP_TITLE, title);
}

View File

@ -27,6 +27,15 @@ public final class PlanStateKeys {
public static final String CURRENT_STEP_RESULT = "current_step_result";
public static final String COMPLETED_RESULTS = "completed_results"; // APPEND 策略
/**
* Number of re-plans performed in THIS graph run (REPLACE strategy). When a
* step throws, the executor re-plans the remaining work around the failure
* (carried in {@link #WORKING_CONTEXT}) instead of aborting outright, up to
* a small bound this counter enforces that bound so a pathological failure
* loop can't re-plan forever. Implicitly 0 at run start.
*/
public static final String PLAN_REPLAN_COUNT = "plan_replan_count";
// ===== 终止 =====
public static final String FINAL_SUMMARY = "final_summary";
public static final String DIRECT_ANSWER = "direct_answer"; // 简单问答的直接回答

View File

@ -80,6 +80,11 @@ public final class MateClawStateAccessor {
return state.value(LLM_CALL_COUNT, 0);
}
/** Iterations refunded this run for setup-only (progressive-disclosure) rounds (0 at run start). */
public int iterationRefundCount() {
return state.value(ITERATION_REFUND_COUNT, 0);
}
// ===== 观察历史 =====
@SuppressWarnings("unchecked")
@ -303,6 +308,11 @@ public final class MateClawStateAccessor {
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
}
/** Hard continuations (fresh-budget ReAct segments) performed this run (0 at run start). */
public int goalHardContinuationCount() {
return state.value(GOAL_HARD_CONTINUATION_COUNT, 0);
}
/**
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
@ -368,6 +378,10 @@ public final class MateClawStateAccessor {
return put(NEEDS_TOOL_CALL, needs);
}
public OutputBuilder iterationRefundCount(int count) {
return put(ITERATION_REFUND_COUNT, count);
}
// ---- 消息 ----
public OutputBuilder messages(List<Message> msgs) {
return put(MESSAGES, msgs);
@ -552,6 +566,10 @@ public final class MateClawStateAccessor {
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
}
public OutputBuilder goalHardContinuationCount(int n) {
return put(GOAL_HARD_CONTINUATION_COUNT, n);
}
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
* immediately re-terminate via the existing final text. */
public OutputBuilder clearFinalAnswer() {
@ -563,6 +581,18 @@ public final class MateClawStateAccessor {
return put(FINISH_REASON, "");
}
/**
* Wipe the limit-exceeded draft + flag. Required before a hard
* continuation re-enters the ReAct loop: FinalAnswerNode prefers
* FINAL_ANSWER_DRAFT over a freshly reasoned answer, so a stale draft
* left by LimitExceededNode would otherwise resurface as the next
* segment's answer.
*/
public OutputBuilder clearLimitExceededDraft() {
put(FINAL_ANSWER_DRAFT, "");
return put(LIMIT_EXCEEDED, false);
}
/** Plan-Execute follow-up: clear the terminal-side plan summary so
* the next PlanGeneration pass starts clean. Identifier is the
* string literal "final_summary" to avoid a compile-time link to

View File

@ -30,6 +30,16 @@ public final class MateClawStateKeys {
public static final String CURRENT_ITERATION = "current_iteration";
public static final String MAX_ITERATIONS = "max_iterations";
/**
* Iterations refunded this run because a reasoning round did no real work
* its whole tool batch was progressive-disclosure setup ({@code load_skill}
* / {@code enable_tool}). ObservationNode skips the iteration increment for
* such rounds so a tight budget isn't eaten by the load-then-use two-step;
* this counter bounds the refunds so a model that only ever loads skills
* still terminates. Implicitly 0 at run start. REPLACE strategy.
*/
public static final String ITERATION_REFUND_COUNT = "iteration_refund_count";
// ===== 工具调用REPLACE 策略=====
public static final String TOOL_CALLS = "tool_calls";
public static final String TOOL_RESULTS = "tool_results";
@ -228,6 +238,22 @@ public final class MateClawStateKeys {
*/
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
/**
* Number of "hard continuations" already performed in THIS graph run a
* hard continuation is a goal follow-up that re-enters the ReAct loop with
* a FRESH iteration budget (CURRENT_ITERATION reset to 0) after a turn that
* ended in {@link FinishReason#MAX_ITERATIONS_REACHED}. Unlike a normal
* follow-up (which shares the run's single iteration budget), a hard
* continuation grants the goal a brand-new ReAct segment so a task too big
* for one budget can keep going autonomously instead of stalling until the
* user sends another message. Because each such segment costs up to a full
* {@code maxIterations} worth of node visits, it is bounded by a dedicated,
* tighter cap ({@code mateclaw.goal.max-hard-continuations-per-run}, clamped
* to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING})
* and sized into the graph recursion ceiling. Implicitly 0 at run start.
*/
public static final String GOAL_HARD_CONTINUATION_COUNT = "goal_hard_continuation_count";
/** Graph-node identifier for the GoalEvaluationNode. */
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";

View File

@ -16,6 +16,16 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "mateclaw.goal")
public class GoalProperties {
/**
* Compile-time ceiling for {@link #maxHardContinuationsPerRun}. The graph
* recursion limit is sized statically to accommodate this many extra
* fresh-budget ReAct segments per run, so the runtime value is clamped to
* it an operator cannot push hard continuations past what the recursion
* backstop was sized for. Raising this requires re-sizing the recursion
* ceiling in {@code AgentGraphBuilder.frameworkRecursionLimit()}.
*/
public static final int MAX_HARD_CONTINUATIONS_CEILING = 3;
/**
* Master switch when off, the graph never invokes GoalEvaluationNode
* (the conditional edge sees no active goal, so the node is unreachable).
@ -39,6 +49,19 @@ public class GoalProperties {
*/
private boolean allowAutoFollowup = true;
/**
* Auto-derive a goal from a multi-step Plan-Execute plan. The Plan-Execute
* planner decomposes the request into steps and the step executor is a
* narrow "task runner" neither calls {@code setGoal}, so without this a
* Plan-Execute run never engages the goal subsystem. When enabled, a goal is
* created server-side at plan generation (title = request, acceptance
* criteria seeded from the plan steps), so the already-wired
* GoalEvaluationNode tracks completion. Gated by {@link #enabled}; only
* fires for genuine multi-step plans and when the conversation has no active
* goal yet. Set to {@code false} to keep Plan-Execute goal-free.
*/
private boolean autoGoalFromPlan = true;
/** Default turn budget when the user doesn't override. */
private int defaultTurnBudget = 20;
@ -57,6 +80,20 @@ public class GoalProperties {
*/
private int maxFollowupsPerRun = 8;
/**
* Max "hard continuations" per single graph run. A hard continuation is a
* goal follow-up that re-enters the ReAct loop with a FRESH iteration
* budget after a turn that hit {@code MAX_ITERATIONS_REACHED} letting a
* task too large for one budget keep going autonomously instead of stalling
* until the user sends another message. Each one costs up to a full
* {@code maxIterations} worth of node visits, so this is a dedicated cap on
* top of {@link #maxFollowupsPerRun}, clamped to
* {@link #MAX_HARD_CONTINUATIONS_CEILING} and sized into the graph recursion
* ceiling. The goal's cross-turn turn / LLM-call budgets still apply. Set to
* 0 to keep the previous behaviour (max-iterations turns end the run).
*/
private int maxHardContinuationsPerRun = 1;
/**
* Provider/model id for the evaluator. Empty string means "use the
* same model as the chat agent" — convenient for dev, expensive in

View File

@ -159,6 +159,10 @@ class LaneDPerformanceFixesTest {
});
var helper = helper(model);
// Shrink backoff to ~1ms so the test doesn't sleep through the real
// 3s/6s exponential backoff; a huge time budget keeps the retry-count
// logic (not the wall-clock cap) the thing under test.
helper.setRetryTimingForTest(1, 1, Long.MAX_VALUE);
var result = helper.streamCall(model, smallPrompt(), "conv-d2a", "reasoning");
// With MAX_RETRIES_RATE_LIMIT=2, attempts are: 0, 1, 2 = 3 total calls
@ -179,6 +183,13 @@ class LaneDPerformanceFixesTest {
});
var helper = helper(model);
// Shrink backoff to ~1ms and lift the wall-clock budget so the full
// MAX_RETRIES path runs to completion. Without this, the real timing
// (exponential backoff capped at 60s vs a 3-minute total budget) cuts
// the loop off at ~8 calls after running for ~4 minutes this test
// is about the retry COUNT, not the time budget (covered separately by
// serverErrorTimeBudgetCapsRetries).
helper.setRetryTimingForTest(1, 1, Long.MAX_VALUE);
var result = helper.streamCall(model, smallPrompt(), "conv-d2b", "reasoning");
// SERVER_ERROR should use the full MAX_RETRIES budget, NOT the reduced
@ -194,6 +205,32 @@ class LaneDPerformanceFixesTest {
"(attempt 0 through " + NodeStreamingChatHelper.MAX_RETRIES + ")");
}
@Test
@DisplayName("SERVER_ERROR stops early when the total-time budget is exhausted")
void serverErrorTimeBudgetCapsRetries() {
AtomicInteger callCount = new AtomicInteger(0);
ChatModel model = mock(ChatModel.class);
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
callCount.incrementAndGet();
return Flux.error(new RuntimeException("500 Internal Server Error"));
});
var helper = helper(model);
// 50ms backoff but only a 10ms total budget: the wall-clock cap not
// MAX_RETRIES bounds a sustained server-error loop. The loop should
// bail after the first backoff pushes elapsed time past the budget,
// well before the full MAX_RETRIES would be consumed.
helper.setRetryTimingForTest(50, 50, 10);
var result = helper.streamCall(model, smallPrompt(), "conv-d2d", "reasoning");
assertTrue(callCount.get() >= 1, "At least the initial attempt should run");
assertTrue(callCount.get() < NodeStreamingChatHelper.MAX_RETRIES + 1,
"Time budget should cut SERVER_ERROR retries short of the full " +
"MAX_RETRIES budget, but got " + callCount.get());
assertNotEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType(),
"Result should be an error after the time budget is exhausted");
}
@Test
@DisplayName("AUTH_ERROR is not retried (unchanged behavior)")
void authErrorNotRetried() {

View File

@ -0,0 +1,237 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalResponse;
import vip.mate.goal.service.GoalEvaluationService;
import vip.mate.goal.service.GoalFollowupService;
import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GraphFlavor;
import vip.mate.workspace.conversation.ConversationService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Locks the goal self-continuation behaviour on terminal turns:
* <ul>
* <li>MAX_ITERATIONS_REACHED now continues with a FRESH iteration budget
* ("hard continuation") instead of skipping silently.</li>
* <li>EVIDENCE_INSUFFICIENT now continues (corrective follow-up) without a
* budget reset.</li>
* <li>STOPPED / RETURN_DIRECT / ERROR_FALLBACK still skip.</li>
* <li>The hard-continuation cap bounds the fresh-budget loop.</li>
* </ul>
*/
class GoalEvaluationNodeContinuationTest {
// ===== Pure decision helpers =====
@Test
void hardSkip_coversUserAndNonProgressTerminals_only() {
assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.STOPPED.getValue()));
assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.RETURN_DIRECT.getValue()));
assertTrue(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.ERROR_FALLBACK.getValue()));
// The two that must now fall through to evaluation:
assertFalse(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.MAX_ITERATIONS_REACHED.getValue()));
assertFalse(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.EVIDENCE_INSUFFICIENT.getValue()));
assertFalse(GoalEvaluationNode.isHardSkipFinishReason(FinishReason.NORMAL.getValue()));
}
@Test
void iterationCapReached_onlyForMaxIterations() {
assertTrue(GoalEvaluationNode.isIterationCapReached(FinishReason.MAX_ITERATIONS_REACHED.getValue()));
assertFalse(GoalEvaluationNode.isIterationCapReached(FinishReason.EVIDENCE_INSUFFICIENT.getValue()));
assertFalse(GoalEvaluationNode.isIterationCapReached(FinishReason.NORMAL.getValue()));
}
// ===== resolveActiveGoal: same-turn activation =====
@Test
void resolveActiveGoal_prefersStateSnapshot_noDbLookup() {
GoalService goalService = mock(GoalService.class);
GoalEntity snap = new GoalEntity();
snap.setId(7L);
OverAllState s = new OverAllState(Map.of(
MateClawStateKeys.ACTIVE_GOAL, snap,
MateClawStateKeys.CONVERSATION_ID, "c1"));
Optional<GoalEntity> out = GoalEvaluationNode.resolveActiveGoal(s, goalService);
assertTrue(out.isPresent());
assertEquals(7L, out.get().getId());
// Snapshot hit must not touch the DB.
verify(goalService, never()).findActiveByConversation(anyString());
}
@Test
void resolveActiveGoal_fallsBackToDb_whenSnapshotEmpty() {
GoalService goalService = mock(GoalService.class);
GoalEntity fromDb = new GoalEntity();
fromDb.setId(9L);
when(goalService.findActiveByConversation("c1")).thenReturn(fromDb);
OverAllState s = new OverAllState(Map.of(MateClawStateKeys.CONVERSATION_ID, "c1"));
Optional<GoalEntity> out = GoalEvaluationNode.resolveActiveGoal(s, goalService);
assertTrue(out.isPresent(), "a goal set mid-turn must be found via the DB fallback");
assertEquals(9L, out.get().getId());
}
@Test
void resolveActiveGoal_emptyWhenNoSnapshotNoDbGoal() {
GoalService goalService = mock(GoalService.class);
when(goalService.findActiveByConversation(anyString())).thenReturn(null);
OverAllState s = new OverAllState(Map.of(MateClawStateKeys.CONVERSATION_ID, "c1"));
assertTrue(GoalEvaluationNode.resolveActiveGoal(s, goalService).isEmpty());
}
// ===== apply() behaviour =====
@Test
void maxIterations_injectsFollowup_andResetsIterationBudget() throws Exception {
Fixture f = new Fixture();
Map<String, Object> out = f.node().apply(
f.state(FinishReason.MAX_ITERATIONS_REACHED.getValue(), 0, 0));
// Followup injected for the run-to-completion loop.
assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED));
assertEquals(1, out.get(MateClawStateKeys.GOAL_FOLLOWUP_COUNT));
// Hard continuation: fresh ReAct segment.
assertEquals(0, out.get(MateClawStateKeys.CURRENT_ITERATION));
assertEquals(1, out.get(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT));
// Stale limit-exceeded draft/flag cleared so it can't resurface.
assertEquals("", out.get(MateClawStateKeys.FINAL_ANSWER_DRAFT));
assertEquals(Boolean.FALSE, out.get(MateClawStateKeys.LIMIT_EXCEEDED));
assertEquals("", out.get(MateClawStateKeys.FINAL_ANSWER));
assertEquals("", out.get(MateClawStateKeys.FINISH_REASON));
// Not a terminal pass the next answer must be re-evaluated.
assertFalse(Boolean.TRUE.equals(out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)));
// Followup appended as a user message.
@SuppressWarnings("unchecked")
List<Message> msgs = (List<Message>) out.get(MateClawStateKeys.MESSAGES);
assertEquals(1, msgs.size());
assertInstanceOf(UserMessage.class, msgs.get(0));
}
@Test
void evidenceInsufficient_injectsFollowup_withoutBudgetReset() throws Exception {
Fixture f = new Fixture();
Map<String, Object> out = f.node().apply(
f.state(FinishReason.EVIDENCE_INSUFFICIENT.getValue(), 0, 0));
assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED));
// No iteration reset / hard-continuation accounting on this path.
assertFalse(out.containsKey(MateClawStateKeys.CURRENT_ITERATION));
assertFalse(out.containsKey(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT));
assertFalse(out.containsKey(MateClawStateKeys.FINAL_ANSWER_DRAFT));
}
@Test
void stopped_skipsEvaluationEntirely() throws Exception {
Fixture f = new Fixture();
Map<String, Object> out = f.node().apply(
f.state(FinishReason.STOPPED.getValue(), 0, 0));
assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
assertFalse(out.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED));
// Evaluator must not even be called on a user-stopped turn.
verify(f.evaluationService, never()).evaluate(any(), anyList(), anyString());
}
@Test
void maxIterations_hardCapReached_endsRunWithoutReset() throws Exception {
Fixture f = new Fixture();
// hardContinuationCount already at the cap (default cap = 1).
Map<String, Object> out = f.node().apply(
f.state(FinishReason.MAX_ITERATIONS_REACHED.getValue(), 0, 1));
// Falls through to the terminal "continue, no followup" path.
assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
assertFalse(out.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED));
assertFalse(out.containsKey(MateClawStateKeys.CURRENT_ITERATION));
}
// ===== Test fixture =====
private static final class Fixture {
final GoalEvaluationService evaluationService = mock(GoalEvaluationService.class);
final GoalFollowupService followupService = mock(GoalFollowupService.class);
final GoalService goalService = mock(GoalService.class);
final GoalProperties properties = new GoalProperties();
final ConversationWindowManager windowManager = mock(ConversationWindowManager.class);
final ConversationService conversationService = mock(ConversationService.class);
Fixture() {
GoalEntity goal = new GoalEntity();
goal.setId(1L);
goal.setTitle("ship the feature");
GoalEvaluationResult continueResult = new GoalEvaluationResult(
0.5, "missing tests", GoalEvaluationResult.DECISION_CONTINUE, false,
"stub-model", 1, 5L, List.of(), null);
lenient().when(evaluationService.evaluate(any(), anyList(), anyString()))
.thenReturn(continueResult);
lenient().when(goalService.getById(eq(1L))).thenReturn(goal);
lenient().when(goalService.isBudgetExhausted(any())).thenReturn(false);
lenient().when(goalService.toResponse(any())).thenReturn(mock(GoalResponse.class));
lenient().when(followupService.maybeBuildFollowup(any(), any()))
.thenReturn(Optional.of("Continue toward the goal. Take the next concrete step."));
}
GoalEvaluationNode node() {
return new GoalEvaluationNode(evaluationService, followupService, goalService,
properties, windowManager, conversationService, GraphFlavor.REACT);
}
/**
* Build a mocked graph state for an active-goal terminal turn.
*
* @param finishReason the REACT finishReason under test
* @param followupCount goal_followup_count already this run
* @param hardContinuationCount goal_hard_continuation_count already this run
*/
OverAllState state(String finishReason, int followupCount, int hardContinuationCount) {
GoalEntity goal = new GoalEntity();
goal.setId(1L);
goal.setTitle("ship the feature");
Map<String, Object> vals = new HashMap<>();
vals.put(MateClawStateKeys.ACTIVE_GOAL, goal);
vals.put(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, false);
vals.put(MateClawStateKeys.FINISH_REASON, finishReason);
vals.put(MateClawStateKeys.AWAITING_APPROVAL, false);
vals.put(MateClawStateKeys.FINAL_ANSWER, "partial answer so far");
vals.put(MateClawStateKeys.LLM_CALL_COUNT, 10);
vals.put(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
vals.put(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, followupCount);
vals.put(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, hardContinuationCount);
return new OverAllState(vals);
}
}
}

View File

@ -0,0 +1,86 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.config.GraphObservationProperties;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* Iteration-refund behaviour: a reasoning round whose entire tool batch was
* progressive-disclosure setup (load_skill / enable_tool) must not consume an
* iteration, bounded by a per-run cap.
*/
class ObservationNodeRefundTest {
private ObservationNode node() {
return new ObservationNode(new ObservationProcessor(new GraphObservationProperties()));
}
private static ToolResponseMessage.ToolResponse result(String name) {
return new ToolResponseMessage.ToolResponse("id-" + name, name, "ok");
}
private OverAllState state(int iteration, int refundCount, List<ToolResponseMessage.ToolResponse> results) {
Map<String, Object> m = new HashMap<>();
m.put(CURRENT_ITERATION, iteration);
m.put(MAX_ITERATIONS, 25);
m.put(ITERATION_REFUND_COUNT, refundCount);
m.put(OBSERVATION_HISTORY, new ArrayList<String>());
m.put(TOOL_RESULTS, results);
m.put(TOOL_CALL_COUNT, 0);
return new OverAllState(m);
}
@Test
@DisplayName("纯渐进披露轮load_skill退还迭代不递增")
void setupOnlyRound_refundsIteration() throws Exception {
Map<String, Object> out = node().apply(state(3, 0, List.of(result("load_skill"))));
assertEquals(3, out.get(CURRENT_ITERATION), "setup-only round must not advance the iteration");
assertEquals(1, out.get(ITERATION_REFUND_COUNT));
}
@Test
@DisplayName("enable_tool 同样视为 setup-only")
void enableToolRound_refundsIteration() throws Exception {
Map<String, Object> out = node().apply(state(5, 1, List.of(result("enable_tool"))));
assertEquals(5, out.get(CURRENT_ITERATION));
assertEquals(2, out.get(ITERATION_REFUND_COUNT));
}
@Test
@DisplayName("真实工具轮正常计费")
void realToolRound_consumesIteration() throws Exception {
Map<String, Object> out = node().apply(state(3, 0, List.of(result("web_search"))));
assertEquals(4, out.get(CURRENT_ITERATION));
assertNull(out.get(ITERATION_REFUND_COUNT), "no refund on a real-work round");
}
@Test
@DisplayName("混合批次(披露+真实工具)正常计费")
void mixedRound_consumesIteration() throws Exception {
Map<String, Object> out = node().apply(
state(3, 0, List.of(result("load_skill"), result("web_search"))));
assertEquals(4, out.get(CURRENT_ITERATION));
assertNull(out.get(ITERATION_REFUND_COUNT));
}
@Test
@DisplayName("退还次数达上限后不再退还")
void refundCapReached_consumesIteration() throws Exception {
// cap is 3; refundCount already 3 -> charged normally
Map<String, Object> out = node().apply(state(7, 3, List.of(result("load_skill"))));
assertEquals(8, out.get(CURRENT_ITERATION));
assertNull(out.get(ITERATION_REFUND_COUNT));
}
}

View File

@ -0,0 +1,58 @@
package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import org.junit.jupiter.api.Test;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Routing coverage for the Plan-Execute step dispatcher, including the
* step-failure re-plan edge (phase=plan_replan PLAN_GENERATION).
*/
class StepProgressDispatcherTest {
private final StepProgressDispatcher dispatcher = new StepProgressDispatcher();
private OverAllState state(String phase, int stepIndex, List<String> steps) {
Map<String, Object> vals = new HashMap<>();
vals.put(MateClawStateKeys.CURRENT_PHASE, phase);
vals.put(PlanStateKeys.CURRENT_STEP_INDEX, stepIndex);
vals.put(PlanStateKeys.PLAN_STEPS, steps);
return new OverAllState(vals);
}
@Test
void replanPhase_routesToPlanGeneration() {
String next = dispatcher.apply(state("plan_replan", 0, List.of("a", "b")));
assertEquals(PlanStateKeys.PLAN_GENERATION_NODE, next);
}
@Test
void abortedPhase_routesToEnd() {
assertEquals(StateGraph.END, dispatcher.apply(state("plan_aborted", 1, List.of("a", "b"))));
}
@Test
void awaitingApproval_routesToEnd() {
assertEquals(StateGraph.END, dispatcher.apply(state("awaiting_approval", 0, List.of("a"))));
}
@Test
void moreStepsRemaining_routesToStepExecution() {
assertEquals(PlanStateKeys.STEP_EXECUTION_NODE,
dispatcher.apply(state("step_completed", 1, List.of("a", "b", "c"))));
}
@Test
void allStepsDone_routesToPlanSummary() {
assertEquals(PlanStateKeys.PLAN_SUMMARY_NODE,
dispatcher.apply(state("step_completed", 2, List.of("a", "b"))));
}
}

View File

@ -0,0 +1,120 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Covers auto-deriving a goal from a multi-step Plan-Execute plan: gating
* conditions and that the plan steps seed the goal's acceptance criteria.
*/
class PlanGenerationAutoGoalTest {
private final GoalService goalService = mock(GoalService.class);
private final GoalProperties properties = new GoalProperties();
private PlanGenerationNode node() {
return new PlanGenerationNode(null, null, null, null, null, goalService, properties);
}
private PlanStateAccessor accessor(boolean withAgent, String goalText) {
ChatOrigin origin = ChatOrigin.web("conv_1", "admin", 1L, null);
if (withAgent) {
origin = origin.withAgent(1000000001L);
}
Map<String, Object> vals = new HashMap<>();
vals.put(MateClawStateKeys.CHAT_ORIGIN, origin);
vals.put(PlanStateKeys.GOAL, goalText);
return new PlanStateAccessor(new OverAllState(vals));
}
@Test
void multiStepPlan_createsGoal_seededWithStepCriteria() {
GoalEntity created = new GoalEntity();
created.setId(99L);
when(goalService.create(any(), eq("admin"))).thenReturn(created);
GoalEntity result = node().maybeAutoCreateGoal(
accessor(true, "分三步完成:读取、分析、汇总"),
List.of("读取文件", "列出建议", "汇总计划"));
assertNotNull(result);
assertEquals(99L, result.getId());
ArgumentCaptor<GoalCreateRequest> cap = ArgumentCaptor.forClass(GoalCreateRequest.class);
verify(goalService).create(cap.capture(), eq("admin"));
GoalCreateRequest req = cap.getValue();
assertEquals("conv_1", req.getConversationId());
assertEquals(1000000001L, req.getAgentId());
// Plan steps become acceptance criteria.
assertNotNull(req.getCriteria());
assertEquals(3, req.getCriteria().size());
assertEquals("读取文件", req.getCriteria().get(0).text());
}
@Test
void featureDisabled_returnsNull_noCreate() {
properties.setAutoGoalFromPlan(false);
GoalEntity result = node().maybeAutoCreateGoal(
accessor(true, "g"), List.of("a", "b"));
assertNull(result);
verify(goalService, never()).create(any(), any());
}
@Test
void singleStepPlan_returnsNull() {
GoalEntity result = node().maybeAutoCreateGoal(accessor(true, "g"), List.of("only one"));
assertNull(result);
verify(goalService, never()).create(any(), any());
}
@Test
void existingActiveGoal_returnsNull_noCreate() {
when(goalService.findActiveByConversation("conv_1")).thenReturn(new GoalEntity());
GoalEntity result = node().maybeAutoCreateGoal(
accessor(true, "g"), List.of("a", "b"));
assertNull(result);
verify(goalService, never()).create(any(), any());
}
@Test
void missingAgentContext_returnsNull() {
GoalEntity result = node().maybeAutoCreateGoal(
accessor(false, "g"), List.of("a", "b"));
assertNull(result);
verify(goalService, never()).create(any(), any());
}
@Test
void masterSwitchOff_returnsNull() {
properties.setEnabled(false);
lenient().when(goalService.create(any(), any())).thenReturn(new GoalEntity());
GoalEntity result = node().maybeAutoCreateGoal(
accessor(true, "g"), List.of("a", "b"));
assertNull(result);
verify(goalService, never()).create(any(), any());
}
}

View File

@ -0,0 +1,66 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.Test;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the re-plan context that StepExecutionNode hands to the next
* PlanGeneration pass after a step fails: it must preserve the completed work
* (carried in WORKING_CONTEXT) and append the failed step + error so the
* planner can route around it.
*/
class StepExecutionReplanContextTest {
private PlanStateAccessor accessor(String workingContext, List<String> steps) {
Map<String, Object> vals = new HashMap<>();
vals.put(PlanStateKeys.WORKING_CONTEXT, workingContext);
vals.put(PlanStateKeys.PLAN_STEPS, steps);
return new PlanStateAccessor(new OverAllState(vals));
}
@Test
void replanContext_preservesPriorWork_andDescribesFailure() {
PlanStateAccessor a = accessor(
"已完成步骤1 读取配置完成",
List.of("读取配置", "迁移数据", "验证结果"));
String ctx = StepExecutionNode.buildReplanContext(a, 1, "connection timeout");
// Prior completed work is carried forward.
assertTrue(ctx.contains("已完成步骤1 读取配置完成"));
// The failed step (1-based) + its title + the error are described.
assertTrue(ctx.contains("步骤 2"));
assertTrue(ctx.contains("迁移数据"));
assertTrue(ctx.contains("connection timeout"));
// Instructs the planner not to redo completed work.
assertTrue(ctx.contains("不要重复"));
}
@Test
void replanContext_handlesEmptyPriorContext() {
PlanStateAccessor a = accessor("", List.of("only step"));
String ctx = StepExecutionNode.buildReplanContext(a, 0, "boom");
// No leading blank separator when there was no prior context.
assertFalse(ctx.startsWith("\n"));
assertTrue(ctx.contains("步骤 1"));
assertTrue(ctx.contains("only step"));
assertTrue(ctx.contains("boom"));
}
@Test
void replanContext_toleratesOutOfRangeIndexAndNullError() {
PlanStateAccessor a = accessor("ctx", List.of("a"));
String ctx = StepExecutionNode.buildReplanContext(a, 5, null);
assertTrue(ctx.contains("步骤 6"));
assertTrue(ctx.contains("未知错误"));
}
}

View File

@ -0,0 +1,74 @@
package vip.mate.agent.graph.plan.node;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Covers the signature-based stall detection: graduated WARN nudge then HALT
* for repeated identical results, repeated identical failing calls, and a tool
* that keeps failing across different arguments.
*/
class StepProgressTrackerTest {
@Test
void identicalResults_warnThenHalt_noProgress() {
StepProgressTracker t = new StepProgressTracker();
// result is a success payload (not a failure marker) -> pure no-progress
assertTrue(t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A").isEmpty(), "1st: no warn");
assertTrue(t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A").isPresent(), "2nd: WARN nudge");
assertFalse(t.isStuck(), "not stuck at WARN");
assertTrue(t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A").isEmpty(), "3rd: nudge de-duped");
t.record("search_files", "{\"q\":\"x\"}", "found 0 matches list A"); // 4th -> HALT
assertTrue(t.isStuck(), "stuck at HALT threshold");
assertTrue(t.haltReason().startsWith("no_progress"));
}
@Test
void sameCallFailing_warnThenHalt() {
StepProgressTracker t = new StepProgressTracker();
// same args, distinct error texts -> isolates the same-call-failure path
assertTrue(t.record("read_file", "{\"p\":\"a\"}", "Error: e1").isEmpty());
assertTrue(t.record("read_file", "{\"p\":\"a\"}", "Error: e2").isPresent(), "2nd failure: WARN");
assertFalse(t.isStuck());
t.record("read_file", "{\"p\":\"a\"}", "Error: e3");
t.record("read_file", "{\"p\":\"a\"}", "Error: e4"); // 4th failure -> HALT
assertTrue(t.isStuck());
assertTrue(t.haltReason().startsWith("repeated_failure"));
}
@Test
void sameToolFailingDifferentArgs_warnThenHalt() {
StepProgressTracker t = new StepProgressTracker();
boolean anyWarn = false;
for (int i = 1; i <= 6; i++) {
Optional<String> n = t.record("terminal", "{\"cmd\":\"c" + i + "\"}", "execution failed: boom" + i);
anyWarn |= n.isPresent();
}
assertTrue(anyWarn, "should emit a same-tool-failure nudge by the 3rd distinct failure");
assertTrue(t.isStuck(), "6 distinct failures of the same tool -> HALT");
}
@Test
void variedSuccessfulResults_noStall() {
StepProgressTracker t = new StepProgressTracker();
for (int i = 0; i < 6; i++) {
assertTrue(t.record("web_search", "{\"q\":\"q" + i + "\"}", "result payload number " + i).isEmpty());
}
assertFalse(t.isStuck(), "distinct successful results never stall");
}
@Test
void looksLikeFailure_classification() {
assertTrue(StepProgressTracker.looksLikeFailure(""), "empty is no-progress");
assertTrue(StepProgressTracker.looksLikeFailure(" "), "blank is no-progress");
assertTrue(StepProgressTracker.looksLikeFailure("Error: ENOENT: no such file or directory"));
assertTrue(StepProgressTracker.looksLikeFailure("java.util.concurrent.TimeoutException: ..."));
assertTrue(StepProgressTracker.looksLikeFailure("Authentication Failed: Requires authentication"));
assertTrue(StepProgressTracker.looksLikeFailure("未找到匹配的文件"));
assertFalse(StepProgressTracker.looksLikeFailure("Here is the summary of the file: ..."));
}
}

View File

@ -34,6 +34,7 @@ class GoalStateKeyDoubleRegistrationTest {
"GOAL_FOLLOWUP_INJECTED",
"GOAL_FOLLOWUP_PROMPT",
"GOAL_EVALUATED_THIS_RUN",
"GOAL_HARD_CONTINUATION_COUNT",
};
@Test

View File

@ -1154,6 +1154,19 @@ watch(currentConversationId, async (cid) => {
}
}, { immediate: true })
// Re-fetch the active goal when a turn finishes. A goal can be created or
// mutated mid-conversation e.g. auto-derived server-side from a Plan-Execute
// plan, or completed by the agent without the conversation id changing and
// without a goal_* SSE event reaching this client (skipped creation, missed
// event, reconnect). The per-conversation watch above only fires on switch, so
// this transition-to-idle refresh is what keeps the goal ring honest after
// every turn against the persisted truth.
watch(isGenerating, async (generating, wasGenerating) => {
if (wasGenerating && !generating && currentConversationId.value) {
await goalStore.loadActiveForConversation(currentConversationId.value)
}
})
// Derive props for the inline prompt + system-line slots that sit
// between MessageList and ChatInput. The prompt shows only when:
// 1) there's a current conversation, agent, and at least one assistant