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:
+67
-2
@@ -41,6 +41,17 @@ type Defaults struct {
|
||||
// absolute max. Never shorter than the run's MaxRuntime. Non-critic runs ignore
|
||||
// it (they keep the literal MaxRuntime kill).
|
||||
CriticAbsoluteMax time.Duration
|
||||
|
||||
// SalvageSingleLoopMaxSteps opts a single-loop run into partial-transcript
|
||||
// salvage on budget exhaustion (ErrMaxSteps / ErrToolLoop / ErrMaxToolCalls):
|
||||
// instead of returning empty Output + a hard error and discarding the
|
||||
// reasoning it produced, the executor reconstructs a best-effort answer from
|
||||
// the step narration (like the phased path) and downgrades the run to a
|
||||
// successful partial result. Default false (off): a structured-output host
|
||||
// (e.g. a bounded review swarm) keeps the clean hard error, where partial
|
||||
// prose masquerading as a valid answer would be worse than a loud failure. A
|
||||
// host that wants the reasoning recovered (interactive research) sets it true.
|
||||
SalvageSingleLoopMaxSteps bool
|
||||
}
|
||||
|
||||
func (d Defaults) withFallbacks() Defaults {
|
||||
@@ -159,6 +170,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
if maxRuntime <= 0 {
|
||||
maxRuntime = e.cfg.Defaults.MaxRuntime
|
||||
}
|
||||
maxCalls := ra.MaxToolCalls // <= 0 => unlimited (no tool-call ceiling)
|
||||
|
||||
// Budget gate (pre-run): a rejected run makes no model call.
|
||||
if e.cfg.Ports.Budget != nil {
|
||||
@@ -203,7 +215,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
rec = e.cfg.Ports.Audit.StartRun(ctx, info)
|
||||
}
|
||||
if rec != nil {
|
||||
stateAcc = NewRunStateAccessor(rec, maxIter, 0, started)
|
||||
stateAcc = NewRunStateAccessor(rec, maxIter, maxCalls, started)
|
||||
inv.RunState = stateAcc
|
||||
}
|
||||
|
||||
@@ -372,6 +384,14 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
// calls with their executed results PAIRWISE — a result without a matching
|
||||
// call (or a call without a result) is skipped rather than recorded as an
|
||||
// empty-name "ghost" step.
|
||||
// Tool-call budget accounting (single-loop enforcement, RunnableAgent.
|
||||
// MaxToolCalls). The step observer runs on majordomo's Run goroutine — the
|
||||
// SAME goroutine that reads the step ceiling — so plain ints suffice (no
|
||||
// atomics). stepCeilingOption reads these to force the loop to exit once the
|
||||
// cap is reached; the caller then relabels the resulting ErrMaxSteps to
|
||||
// ErrMaxToolCalls. maxCalls <= 0 (unlimited) leaves this inert.
|
||||
var toolCallsSeen, stepsSeen int
|
||||
var capHit bool
|
||||
emitter := newStepEmitter(inv.OnStep)
|
||||
stepObserver := func(s agent.Step) {
|
||||
if stateAcc != nil {
|
||||
@@ -398,6 +418,21 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
rec.OnTool(call, r.Content)
|
||||
}
|
||||
}
|
||||
// Tool-call budget: accumulate executed calls and note the crossing once.
|
||||
// Only single-loop runs enforce the cap (phased pipelines are a follow-up),
|
||||
// so gate the crossing detection on Phases to avoid a misleading audit
|
||||
// event on a phased run that merely happens to carry a cap.
|
||||
toolCallsSeen += n
|
||||
stepsSeen = s.Index + 1
|
||||
if len(ra.Phases) == 0 && maxCalls > 0 && toolCallsSeen >= maxCalls && !capHit {
|
||||
capHit = true
|
||||
if rec != nil {
|
||||
rec.LogEvent("max_tool_calls_reached", map[string]any{
|
||||
"max_tool_calls": maxCalls,
|
||||
"tool_calls": toolCallsSeen,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared agent options used by BOTH the single-loop path and every phase: the
|
||||
@@ -480,7 +515,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
}
|
||||
opts := append([]agent.Option{
|
||||
agent.WithToolbox(toolbox),
|
||||
critic.maxStepsOption(maxIter),
|
||||
critic.stepCeilingOption(maxIter, maxCalls, &toolCallsSeen, &stepsSeen),
|
||||
agent.WithStepObserver(obs),
|
||||
}, sharedOpts...)
|
||||
ag := agent.New(model, e.systemPrompt(ra), opts...)
|
||||
@@ -492,6 +527,15 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
runRes, runErr = runAgent(runCtx, ag, input, inv.Images, agent.WithSteer(steer))
|
||||
}
|
||||
|
||||
// A tool-call-cap exit surfaces from majordomo as ErrMaxSteps (we drove
|
||||
// the step ceiling down to force the loop to stop). Relabel it so status,
|
||||
// salvage, and audit report the real cause. A capped step always carries
|
||||
// tool calls (a final-answer turn has zero), so capHit + ErrMaxSteps
|
||||
// pinpoints a cap-triggered exit.
|
||||
if capHit && errors.Is(runErr, agent.ErrMaxSteps) {
|
||||
runErr = fmt.Errorf("%w (max %d)", ErrMaxToolCalls, maxCalls)
|
||||
}
|
||||
|
||||
// ResultSchema (optional): validate the final answer and let the model
|
||||
// self-repair on the same conversation before the host sees it. Runs
|
||||
// BEFORE FinalGuard so the delivery check inspects the answer that will
|
||||
@@ -570,6 +614,27 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-loop budget-exhaustion salvage (opt-in). A loop that hit its
|
||||
// step/tool-call budget without composing a final answer returns empty
|
||||
// Output + a hard error, discarding the reasoning it DID produce. Mirror
|
||||
// the phased path: reconstruct a best-effort answer from the step
|
||||
// narration and downgrade the hard error to a successful partial result.
|
||||
// This runs only after a budget error — ResultSchema + FinalGuard above
|
||||
// are gated on runErr == nil, so neither fired. Gated on the host default
|
||||
// so a structured-output host keeps its clean hard error.
|
||||
if e.cfg.Defaults.SalvageSingleLoopMaxSteps &&
|
||||
isBudgetExhaustion(runErr) && runRes != nil &&
|
||||
strings.TrimSpace(runRes.Output) == "" {
|
||||
if salvaged := salvagePhaseTranscript(runRes); salvaged != "" {
|
||||
runRes.Output = salvaged +
|
||||
"\n\n(Note: reached its step budget before composing a final answer; the above is its partial reasoning.)"
|
||||
if rec != nil {
|
||||
rec.LogEvent("single_loop_salvaged", map[string]any{"error": runErr.Error()})
|
||||
}
|
||||
runErr = nil // downgrade: status becomes "ok" and Output is delivered
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-phase pipeline: each phase runs its own prompt/tier/tools/step-cap
|
||||
// sequentially, threading outputs through {{.<PhaseName>}} templates. The
|
||||
|
||||
Reference in New Issue
Block a user