feat(run): single-loop max-steps salvage + enforce MaxToolCalls
Two gaps surfaced by a live fan-out smoke in a downstream host (mort): 1) A single-loop run that exhausts its step budget (ErrMaxSteps / ErrToolLoop) returned empty Output + a hard error, discarding all the reasoning it had produced — unlike the phased path, which already salvages a partial transcript and continues. Add opt-in single-loop salvage (Defaults.SalvageSingleLoopMaxSteps, default off): on budget exhaustion with empty Output, reconstruct a best-effort answer from the step narration (reusing salvagePhaseTranscript) and downgrade the run to a successful partial result. Off by default so a structured-output host keeps its clean hard error; an interactive host opts in. 2) RunnableAgent carried no tool-call ceiling — MaxIterations bounds STEPS, but one step can dispatch several tool calls, so a host's "max tool calls" cap had nowhere to land. Add RunnableAgent.MaxToolCalls (<=0 = unlimited, backward-compatible): the step observer counts executed calls and, once the cap is reached, stepCeilingOption forces the loop to exit; the resulting ErrMaxSteps is relabeled to the new ErrMaxToolCalls sentinel. Enforced single-loop only for now (phased is a follow-up). A cap hit is budget exhaustion, so salvage recovers its partial reasoning too. isPhaseBudgetExhaustion becomes the shared isBudgetExhaustion (now also matching ErrMaxToolCalls) so the phased and single-loop paths never drift, and the RunStateAccessor now reports the real tool-call cap (was hardcoded 0). Additive + backward-compatible: new optional DTO field, new opt-in Defaults flag, new exported sentinel; unlimited/off reproduce prior behavior exactly. No new dependencies (go mod tidy clean). Tests cover salvage on/off/no-prose, cap enforcement + sentinel, cap+salvage, unlimited no-op, and cap-overrides-critic. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Q1eJh5NVR11z6RzyxLANMK
This commit is contained in:
+11
-7
@@ -156,7 +156,7 @@ func (e *Executor) runPhases(runCtx context.Context, ra RunnableAgent, deps phas
|
||||
deps.rec.LogEvent("phase_failed_optional", map[string]any{"phase": phase.Name, "error": err.Error()})
|
||||
}
|
||||
|
||||
case isPhaseBudgetExhaustion(err) && (!isLast || trimmed != ""):
|
||||
case isBudgetExhaustion(err) && (!isLast || trimmed != ""):
|
||||
// Soft stop: the phase ran out of its step/tool budget before
|
||||
// composing a final answer. Not fatal — it did real work (runOnePhase
|
||||
// salvaged its partial transcript into output), and aborting would
|
||||
@@ -248,7 +248,7 @@ func (e *Executor) runOnePhase(runCtx context.Context, ra RunnableAgent, deps ph
|
||||
}
|
||||
// Budget/guard exhaustion leaves a usable partial transcript but an empty
|
||||
// final answer; salvage the narrated work so the pipeline can carry it forward.
|
||||
if runErr != nil && isPhaseBudgetExhaustion(runErr) {
|
||||
if runErr != nil && isBudgetExhaustion(runErr) {
|
||||
if salvaged := salvagePhaseTranscript(res); salvaged != "" {
|
||||
output = salvaged
|
||||
}
|
||||
@@ -278,11 +278,15 @@ func (e *Executor) phaseModel(ctx context.Context, deps phaseDeps, ra RunnableAg
|
||||
return modelCtx, m
|
||||
}
|
||||
|
||||
// isPhaseBudgetExhaustion reports whether err is a soft budget/guard stop (the
|
||||
// loop hit its step cap or tripped a tool-error guard) — which leaves a usable
|
||||
// partial transcript — as opposed to a hard error (cancellation, model failure).
|
||||
func isPhaseBudgetExhaustion(err error) bool {
|
||||
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
|
||||
// isBudgetExhaustion reports whether err is a soft budget/guard stop (the loop
|
||||
// hit its step cap, its tool-call cap, or tripped a tool-error guard) — which
|
||||
// leaves a usable partial transcript — as opposed to a hard error (cancellation,
|
||||
// model failure). Shared by the phased pipeline and the single-loop salvage in
|
||||
// executor.go, so the two paths never drift on what counts as exhaustion.
|
||||
func isBudgetExhaustion(err error) bool {
|
||||
return errors.Is(err, agent.ErrMaxSteps) ||
|
||||
errors.Is(err, agent.ErrToolLoop) ||
|
||||
errors.Is(err, ErrMaxToolCalls)
|
||||
}
|
||||
|
||||
// maxSalvageBytes bounds a salvaged partial transcript so a long phase's narrated
|
||||
|
||||
Reference in New Issue
Block a user