9 Commits
Author SHA1 Message Date
steve 55a8fb8866 Merge pull request 'feat(run): single-loop max-steps salvage + enforce MaxToolCalls' (#25) from feat/single-loop-salvage-max-tool-calls into main
executus CI / test (push) Successful in 1m56s
2026-07-17 19:53:21 +00:00
steveandClaude Opus 4.8 13be3022fd feat(run): single-loop max-steps salvage + enforce MaxToolCalls
executus CI / test (pull_request) Successful in 2m2s
Gadfly review (reusable) / review (pull_request) Successful in 9m5s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m6s
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
2026-07-17 15:35:50 -04:00
steve 25563e1f21 Merge pull request 'feat(run): kernel-native result_schema — validate + in-loop self-repair' (#24) from feat/result-schema-submit into main
executus CI / test (push) Successful in 2m12s
2026-07-17 17:32:31 +00:00
steveandClaude Fable 5 a6b185985a fix(review): result_schema gadfly findings + go mod tidy
executus CI / test (pull_request) Successful in 50s
- Extracted applyResultSchema (panic-isolated like safeFinalNudge — a
  validator bug must never convert a successful run into a panic error)
- Blank corrective-round reply no longer clobbers the prior candidate
- result_schema_valid now fires on first-try conforming answers too
- Multi-phase runs log WARN + result_schema_skipped instead of a
  silent drop
- errors.As instead of the hand-rolled assertion; redundant guard
  removed; go.mod/go.sum tidied (the CI failure)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 13:30:04 -04:00
steveandClaude Fable 5 b4080a8a6e feat(run): kernel-native result_schema — validate + in-loop self-repair
executus CI / test (pull_request) Failing after 38s
Gadfly review (reusable) / review (pull_request) Successful in 11m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 11m34s
tool.Invocation.ResultSchema (JSON Schema, draft 2020-12): the executor
validates the single-loop final answer and, on violation, runs up to two
bounded corrective rounds on the same conversation (FinalGuard-style
extra rounds, but the round's text IS the new candidate); a conforming
answer is replaced by the bare validated JSON. Fail-open on compile
problems (host pre-dispatch compile is the authoritative gate) and
best-effort on persistent violations (last output stands, hosts keep
their fallback validator). External $refs fail closed — jsonschema/v6's
default loader resolves file:// from local disk, overridden with a deny
loader. Audit events: result_schema_round/_valid/_unmet/_skipped.
Multi-phase runs unvalidated (FinalGuard's single-loop-only scope).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 13:17:43 -04:00
steveandClaude Fable 5 46e4eb3da6 ci: pin gadfly reusable @8eb0265 + thread dispatch pr_number [skip ci]
Gitea >= 1.27 does not propagate workflow_dispatch inputs into a called
workflow's github.event; the stub must pass pr_number as an explicit
workflow_call input or manual dispatches die at 'PR required'. Mirrors
gadfly#24.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:34:55 -04:00
steveandClaude Fable 5 9c6b819f71 ci: pin gadfly reusable @3664ce8 (ragnaros endpoint replaced by netherstorm) [skip ci]
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:32:44 -04:00
steveandClaude Fable 5 cc3739494c ci: pin gadfly reusable @f542d4e (forward netherstorm endpoint) [skip ci]
The netherstorm reviewer failed with 'unknown provider': the correctly
formatted GADFLY_ENDPOINT_NETHERSTORM user var was never forwarded by
the reusable workflow (hardcoded env list). Mirrors gadfly#22.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:31:05 -04:00
steveandClaude Fable 5 17e329f935 ci: pin gadfly reusable @b6a33dc (Gitea 1.27 workflow_call hotfix) [skip ci]
Gitea 1.27 hands called workflows event_name=workflow_call, so every
review since 2026-07-14 self-skipped in 1s while reporting success. The
hotfix lineage (gadfly 5007597 + entrypoint reclassification, image
sha-ed9e946) restores the exact pre-upgrade reviewer; gadfly main
carries the same fix for the executus re-platform rollout.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-15 23:02:08 -04:00
12 changed files with 922 additions and 20 deletions
+4 -1
View File
@@ -42,7 +42,7 @@ jobs:
# and cache the reusable-workflow ref, so a moved v1 tag keeps resolving to the
# stale cached copy. A unique sha forces a cache miss → fresh fetch. Bump this
# sha to adopt central swarm changes.
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@5007597cf921dc3f0a83c708878facfe65fd8e8b
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@8eb0265e759e8c37ade8eaa8cb4b3aa9c7a5a169
# Least privilege: forward only the review secrets (not `secrets: inherit`,
# which would expose every repo secret). GITEA_TOKEN is the automatic token.
secrets:
@@ -53,3 +53,6 @@ jobs:
with:
# Consumer-specific allow-list; everything else is inherited.
allowed_users: "steve,fizi,dazed"
# Gitea >= 1.27 does not propagate dispatch inputs into a called workflow's
# github.event — thread the PR number explicitly (empty on non-dispatch events).
pr_number: ${{ github.event.inputs.pr_number }}
+1
View File
@@ -6,6 +6,7 @@ require (
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3
github.com/google/uuid v1.6.0
github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/crypto v0.53.0
gopkg.in/yaml.v3 v3.0.1
)
+4
View File
@@ -13,6 +13,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -52,6 +54,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+9
View File
@@ -37,6 +37,15 @@ type RunnableAgent struct {
MaxIterations int
MaxRuntime time.Duration
// MaxToolCalls caps the TOTAL number of tool calls across the whole run,
// independent of the step ceiling (one step can carry several parallel tool
// calls). 0 (or negative) = unlimited — the backward-compatible default, so
// a run that never sets it is unaffected. Enforced at step granularity: the
// loop stops after the step in which the cumulative tool-call count reaches
// the cap, surfacing ErrMaxToolCalls. Single-loop runs only today (phased
// pipelines are a follow-up).
MaxToolCalls int
// LowLevelTools are tool-registry names the run may call directly.
// SkillPalette / SubAgentPalette name saved skills / sub-agents exposed as
// skill__<name> / agent__<name> delegation tools, resolved through
+26 -8
View File
@@ -110,18 +110,36 @@ func (b *criticBinding) recordToolStart(name, args string) {
}
}
// maxStepsOption returns the agent step-ceiling Option. With no critic it's a
// fixed WithMaxSteps(base); with a critic it's a DYNAMIC WithMaxStepsFunc that
// polls the handle each step (so the critic can raise a long run's budget),
// falling back to base when the handle defers (MaxSteps() <= 0).
func (b *criticBinding) maxStepsOption(base int) agent.Option {
if b == nil {
return agent.WithMaxSteps(base)
}
// stepCeilingOption returns the agent step-ceiling Option, folding the run's
// tool-call cap into the (optionally critic-driven) step ceiling. Priority:
//
// 1. tool-call cap hit (maxCalls > 0 && *toolCalls >= maxCalls): return the
// completed-step count so majordomo's `stepIdx < ceiling` check fails on the
// next iteration and the loop exits (the executor relabels the resulting
// ErrMaxSteps to ErrMaxToolCalls). Never returns <= 0 — majordomo would fall
// back to its own static ceiling — so a cap hit before any step completes
// clamps to 1.
// 2. the critic's DYNAMIC ceiling, when a critic is bound and raises a long
// run's budget mid-flight (b.h.MaxSteps() > 0).
// 3. the static base (MaxIterations).
//
// With maxCalls <= 0 and no critic this is exactly the old WithMaxSteps(base):
// an unlimited-tool-call run is a true no-op. nil-safe on the binding (b == nil
// skips the critic branch). toolCalls/steps are read live from the step
// observer's counters (same goroutine as this poll — see executor.go).
func (b *criticBinding) stepCeilingOption(base, maxCalls int, toolCalls, steps *int) agent.Option {
return agent.WithMaxStepsFunc(func() int {
if maxCalls > 0 && toolCalls != nil && *toolCalls >= maxCalls {
if steps != nil && *steps > 0 {
return *steps
}
return 1
}
if b != nil {
if n := b.h.MaxSteps(); n > 0 {
return n
}
}
return base
})
}
+89 -2
View File
@@ -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,28 @@ 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
// actually be returned. Fail-open on compile problems; best-effort on
// persistent violations (the host's fallback validator is authoritative).
if runErr == nil && runRes != nil && len(inv.ResultSchema) > 0 {
e.applyResultSchema(runCtx, resultSchemaDeps{
inv: inv, ra: ra, runRes: runRes, rec: rec,
model: model, toolbox: toolbox, sharedOpts: sharedOpts,
obs: obs, steer: steer, noteCheckpoint: noteCheckpoint,
})
}
// FinalGuard (optional): the run finished successfully — before the result
// is finalized, let the host inspect it for undelivered work (the
// announce-then-stop failure: an artifact rendered into run storage, a
@@ -557,12 +614,42 @@ 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
// shared step observer (audit/steps/critic) is wired per phase by the phase
// runner; checkpointing is phase-boundary granular (completed phases are
// recorded so a resumed run skips them).
//
// ResultSchema is single-loop-only (FinalGuard's scope) — make the drop
// observable rather than silent.
if len(inv.ResultSchema) > 0 {
slog.Warn("run: result_schema ignored on multi-phase run", "run_id", inv.RunID)
if rec != nil {
rec.LogEvent("result_schema_skipped", map[string]any{"error": "multi-phase runs are not schema-validated"})
}
}
runRes, runErr = e.runPhases(runCtx, ra, phaseDeps{
baseModel: model,
baseToolbox: toolbox,
+11 -7
View File
@@ -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
+8
View File
@@ -16,6 +16,14 @@ import (
// kill via KillCause(); the executor wraps that reason with this sentinel.
var ErrCriticKill = errors.New("run: critic killed the run")
// ErrMaxToolCalls is the cause when a run reaches its RunnableAgent.MaxToolCalls
// budget before composing a final answer. It surfaces from majordomo as
// ErrMaxSteps (the kernel forces the step ceiling down to stop the loop) and is
// relabeled to this sentinel so audit + status report the real cause. Treated
// as budget exhaustion by isBudgetExhaustion, so the single-loop salvage path
// recovers a partial answer from it when enabled.
var ErrMaxToolCalls = errors.New("run: max tool calls reached without a final answer")
// Ports are the host seams the run executor consumes. Every field is nil-safe:
// a light host passes the zero Ports and gets a bounded, in-memory run with no
// persistence, audit, budget, critic, delegation, or delivery — which is
+295
View File
@@ -0,0 +1,295 @@
package run
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// result_schema.go — kernel-native structured output for single-loop runs.
//
// When tool.Invocation.ResultSchema is set, the executor validates the loop's
// final answer against the schema and, on violation, sends the loop back for
// bounded corrective rounds ON THE SAME CONVERSATION — the model repairs its
// own answer with full task context, which beats any post-hoc host-side
// reformat (the host can't know what the model meant). Mirrors the FinalGuard
// extra-round mechanics (same model, same observers, small step cap), with one
// deliberate contrast: FinalGuard discards the extra round's text, while a
// schema round's text IS the new candidate answer.
//
// Fail-open posture: a schema that doesn't compile logs a WARN and skips
// validation entirely — the host's pre-dispatch compile is the authoritative
// gate, and the kernel must not brick runs on a host bug. Persistent
// violations keep the last candidate output; hosts layer their own fallback
// validator/envelope on top.
const (
// resultSchemaMaxBytes caps the schema the kernel will compile.
resultSchemaMaxBytes = 64 << 10
// resultSchemaMaxRounds caps corrective rounds per run.
resultSchemaMaxRounds = 2
// resultSchemaRoundMaxSteps caps each corrective round. Producing a
// corrected JSON document needs no tools; 2 steps tolerates one stray
// tool call before the reply.
resultSchemaRoundMaxSteps = 2
// resultSchemaExtractCap bounds the output size scanned for a JSON
// document (the balanced-span scan is quadratic in the worst case).
resultSchemaExtractCap = 64 << 10
)
// denyLoader fails every $ref resolution. jsonschema/v6's default compiler
// resolves file:// refs from local disk (and can be configured for http) —
// a caller-supplied schema must never reach either.
type denyLoader struct{}
func (denyLoader) Load(url string) (any, error) {
return nil, fmt.Errorf("external $ref %q refused: remote/file schema loading is disabled: %w", url, fs.ErrNotExist)
}
// compileResultSchema compiles a raw schema with external loading disabled.
func compileResultSchema(raw json.RawMessage) (*jsonschema.Schema, error) {
if len(raw) > resultSchemaMaxBytes {
return nil, fmt.Errorf("schema is %d bytes (cap %d)", len(raw), resultSchemaMaxBytes)
}
doc, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw)))
if err != nil {
return nil, fmt.Errorf("schema is not valid JSON: %w", err)
}
c := jsonschema.NewCompiler()
c.UseLoader(denyLoader{})
if err := c.AddResource("inline://result-schema", doc); err != nil {
return nil, err
}
return c.Compile("inline://result-schema")
}
// extractResultJSON finds the answer's JSON document: the whole string, a
// fenced block, or the first parseable balanced {...}/[...] span scanning
// left to right. Input is capped at resultSchemaExtractCap.
func extractResultJSON(s string) (json.RawMessage, bool) {
s = strings.TrimSpace(s)
if len(s) > resultSchemaExtractCap {
s = s[:resultSchemaExtractCap]
}
if json.Valid([]byte(s)) {
return json.RawMessage(s), true
}
// Fenced block: ```json ... ``` or bare ``` ... ```.
for _, fence := range []string{"```json", "```"} {
if i := strings.Index(s, fence); i >= 0 {
rest := s[i+len(fence):]
if j := strings.Index(rest, "```"); j >= 0 {
candidate := strings.TrimSpace(rest[:j])
if json.Valid([]byte(candidate)) && candidate != "" {
return json.RawMessage(candidate), true
}
}
}
}
// First parseable balanced span.
for i := 0; i < len(s); i++ {
if s[i] != '{' && s[i] != '[' {
continue
}
if end, ok := balancedSpanEnd(s, i); ok {
candidate := s[i : end+1]
if json.Valid([]byte(candidate)) {
return json.RawMessage(candidate), true
}
}
}
return nil, false
}
// balancedSpanEnd returns the index of the bracket closing the span opened
// at start, honoring JSON string/escape rules.
func balancedSpanEnd(s string, start int) (int, bool) {
depth := 0
inStr := false
esc := false
for i := start; i < len(s); i++ {
c := s[i]
if inStr {
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = false
}
continue
}
switch c {
case '"':
inStr = true
case '{', '[':
depth++
case '}', ']':
depth--
if depth == 0 {
return i, true
}
}
}
return 0, false
}
// validateResultJSON returns flattened error paths, nil when the doc conforms.
func validateResultJSON(sch *jsonschema.Schema, doc json.RawMessage) []string {
inst, err := jsonschema.UnmarshalJSON(strings.NewReader(string(doc)))
if err != nil {
return []string{"document is not valid JSON: " + err.Error()}
}
err = sch.Validate(inst)
if err == nil {
return nil
}
var ve *jsonschema.ValidationError
if !errors.As(err, &ve) {
return []string{err.Error()}
}
var out []string
flattenValidationError(ve, &out)
sort.Strings(out)
if len(out) == 0 {
out = []string{ve.Error()}
}
return out
}
// flattenValidationError collects leaf causes as "/path: message" lines.
func flattenValidationError(ve *jsonschema.ValidationError, out *[]string) {
if len(ve.Causes) == 0 {
loc := "/" + strings.Join(ve.InstanceLocation, "/")
if len(ve.InstanceLocation) == 0 {
loc = "/"
}
*out = append(*out, fmt.Sprintf("%s: %v", loc, ve.ErrorKind))
return
}
for _, c := range ve.Causes {
flattenValidationError(c, out)
}
}
// resultSchemaDeps bundles the per-run state applyResultSchema needs from
// the executor's single-loop path.
type resultSchemaDeps struct {
inv tool.Invocation
ra RunnableAgent
runRes *agent.Result
rec RunRecorder
model llm.Model
toolbox *llm.Toolbox
sharedOpts []agent.Option
obs func(agent.Step)
steer func() []llm.Message
noteCheckpoint func(llm.Message)
}
// applyResultSchema runs the validate + corrective-round loop, mutating
// d.runRes in place. Panic-isolated like safeFinalNudge: a bug in the
// validator (or a pathological schema) must never convert a successful
// run into a panic error.
func (e *Executor) applyResultSchema(runCtx context.Context, d resultSchemaDeps) {
defer func() {
if r := recover(); r != nil {
slog.Error("run: result_schema validation panicked; keeping run result",
"run_id", d.inv.RunID, "panic", r)
}
}()
sch, cerr := compileResultSchema(d.inv.ResultSchema)
if cerr != nil {
slog.Warn("run: result_schema failed to compile; skipping kernel validation",
"run_id", d.inv.RunID, "error", cerr)
if d.rec != nil {
d.rec.LogEvent("result_schema_skipped", map[string]any{"error": cerr.Error()})
}
return
}
for round := 0; ; round++ {
var errs []string
doc, ok := extractResultJSON(d.runRes.Output)
if ok {
errs = validateResultJSON(sch, doc)
if errs == nil {
// Conforming: the bare validated document IS the answer.
d.runRes.Output = string(doc)
if d.rec != nil {
d.rec.LogEvent("result_schema_valid", map[string]any{"rounds": round})
}
return
}
} else {
errs = []string{"no JSON document found in output"}
}
if round >= resultSchemaMaxRounds {
// Best effort exhausted — keep the last candidate; the host's
// own validator decides what to do with it.
if d.rec != nil {
d.rec.LogEvent("result_schema_unmet", map[string]any{
"rounds": round, "validation_errors": errs,
})
}
return
}
correction := resultSchemaCorrection(d.inv.ResultSchema, errs)
if d.rec != nil {
d.rec.LogEvent("result_schema_round", map[string]any{
"round": round + 1, "validation_errors": errs,
})
}
if d.noteCheckpoint != nil {
d.noteCheckpoint(llm.UserText(correction))
}
roundOpts := append([]agent.Option{
agent.WithToolbox(d.toolbox),
agent.WithMaxSteps(resultSchemaRoundMaxSteps),
agent.WithStepObserver(d.obs),
}, d.sharedOpts...)
roundAg := agent.New(d.model, e.systemPrompt(d.ra), roundOpts...)
roundRes, roundErr := roundAg.Run(runCtx, correction,
agent.WithSteer(d.steer), agent.WithHistory(d.runRes.Messages))
if roundRes != nil {
roundUsage := roundRes.Usage
d.runRes.Usage.Add(roundUsage)
}
if roundErr != nil || roundRes == nil {
slog.Warn("run: result_schema corrective round failed; keeping last output",
"run_id", d.inv.RunID, "error", roundErr)
return
}
// The round's text is the NEW candidate (contrast FinalGuard, whose
// extra-round text is discarded meta-commentary) — but a BLANK round
// reply must not clobber the prior non-empty candidate.
if strings.TrimSpace(roundRes.Output) != "" {
d.runRes.Output = roundRes.Output
}
d.runRes.Messages = roundRes.Messages
}
}
// resultSchemaCorrection builds the corrective user turn for one round.
func resultSchemaCorrection(raw json.RawMessage, errs []string) string {
return fmt.Sprintf(`Your final answer must be a SINGLE JSON document conforming to this JSON Schema (no prose, no code fences):
%s
Your previous answer failed validation:
- %s
Reply now with ONLY the corrected JSON document. Preserve your answer's content; do not invent data; use null or empty values for anything missing.`,
string(raw), strings.Join(errs, "\n- "))
}
+208
View File
@@ -0,0 +1,208 @@
package run
import (
"context"
"encoding/json"
"strings"
"testing"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
func resultSchemaExecutor(t *testing.T, m llm.Model, rec RunRecorder) *Executor {
t.Helper()
ports := Ports{}
if rec != nil {
ports.Audit = auditFunc{start: func(RunInfo) RunRecorder { return rec }}
}
return New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: ports,
})
}
var personSchema = json.RawMessage(`{"type":"object","required":["name"],"properties":{"name":{"type":"string"}},"additionalProperties":false}`)
// TestResultSchema_ConformingAnswerPassesThrough — a first-try conforming
// answer (fence-wrapped, even) is replaced by the bare JSON with no extra
// model rounds.
func TestResultSchema_ConformingAnswerPassesThrough(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("Here you go:\n```json\n{\"name\": \"mort\"}\n```"))
m, _ := fp.Model("m")
ex := resultSchemaExecutor(t, m, nil)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs1", CallerID: "c", ResultSchema: personSchema}, "who are you")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != `{"name": "mort"}` {
t.Fatalf("output = %q, want the bare validated JSON", res.Output)
}
if n := fp.CallCount("m"); n != 1 {
t.Fatalf("model calls = %d, want 1 (no corrective round)", n)
}
}
// TestResultSchema_CorrectiveRoundRepairs — a non-conforming first answer
// triggers one corrective round whose text becomes the run output.
func TestResultSchema_CorrectiveRoundRepairs(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply(`{"name": 42}`), // wrong type
fake.Reply(`{"name": "fixed"}`), // corrective round conforms
)
m, _ := fp.Model("m")
rec := &eventRecorder{}
ex := resultSchemaExecutor(t, m, rec)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs2", CallerID: "c", ResultSchema: personSchema}, "go")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != `{"name": "fixed"}` {
t.Fatalf("output = %q, want the corrected JSON", res.Output)
}
if n := fp.CallCount("m"); n != 2 {
t.Fatalf("model calls = %d, want 2", n)
}
if !recHas(rec, "result_schema_round") || !recHas(rec, "result_schema_valid") {
t.Fatalf("expected round + valid audit events, got %v", rec.events)
}
}
// TestResultSchema_PersistentViolationKeepsLastOutput — rounds exhaust, the
// last candidate stands, and result_schema_unmet is recorded (the host's own
// validator is the fallback authority).
func TestResultSchema_PersistentViolationKeepsLastOutput(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply("not json at all"),
fake.Reply("still prose"),
fake.Reply("prose forever"),
)
m, _ := fp.Model("m")
rec := &eventRecorder{}
ex := resultSchemaExecutor(t, m, rec)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs3", CallerID: "c", ResultSchema: personSchema}, "go")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "prose forever" {
t.Fatalf("output = %q, want the last candidate kept", res.Output)
}
if n := fp.CallCount("m"); n != 1+resultSchemaMaxRounds {
t.Fatalf("model calls = %d, want %d", n, 1+resultSchemaMaxRounds)
}
if !recHas(rec, "result_schema_unmet") {
t.Fatalf("expected result_schema_unmet, got %v", rec.events)
}
}
// TestResultSchema_NoSchemaUnchanged — anchor: without a schema the output
// and call count are untouched.
func TestResultSchema_NoSchemaUnchanged(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("plain prose answer"))
m, _ := fp.Model("m")
ex := resultSchemaExecutor(t, m, nil)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs4", CallerID: "c"}, "go")
if res.Err != nil || res.Output != "plain prose answer" {
t.Fatalf("output = %q err = %v", res.Output, res.Err)
}
if n := fp.CallCount("m"); n != 1 {
t.Fatalf("model calls = %d, want 1", n)
}
}
// TestResultSchema_CompileFailureFailsOpen — an uncompilable schema skips
// validation (WARN + audit event), never bricks the run.
func TestResultSchema_CompileFailureFailsOpen(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("prose"))
m, _ := fp.Model("m")
rec := &eventRecorder{}
ex := resultSchemaExecutor(t, m, rec)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs5", CallerID: "c",
ResultSchema: json.RawMessage(`{"type": "not-a-type"}`)}, "go")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "prose" {
t.Fatalf("output = %q, want untouched", res.Output)
}
if !recHas(rec, "result_schema_skipped") {
t.Fatalf("expected result_schema_skipped, got %v", rec.events)
}
}
// TestCompileResultSchema_ExternalRefsFailClosed — http(s) AND file $refs
// must not resolve (jsonschema/v6's default loader reads local disk).
func TestCompileResultSchema_ExternalRefsFailClosed(t *testing.T) {
for _, ref := range []string{
"https://example.com/schema.json",
"file:///etc/passwd",
} {
raw := json.RawMessage(`{"$ref": "` + ref + `"}`)
if _, err := compileResultSchema(raw); err == nil {
t.Errorf("$ref %q compiled — external loading must fail closed", ref)
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "not exist") {
// Any error is acceptable as long as it FAILS; the refused
// wording just confirms the deny loader was consulted.
t.Logf("$ref %q failed with: %v", ref, err)
}
}
}
// TestExtractResultJSON_Shapes — extraction tolerance.
func TestExtractResultJSON_Shapes(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{`{"a":1}`, `{"a":1}`, true},
{"prefix text {\"a\": {\"b\": \"}\"}} suffix", `{"a": {"b": "}"}}`, true},
{"```json\n[1,2]\n```", `[1,2]`, true},
{"no json here", "", false},
{"broken { \"a\": ", "", false},
}
for _, c := range cases {
got, ok := extractResultJSON(c.in)
if ok != c.ok || (ok && string(got) != c.want) {
t.Errorf("extract(%q) = (%q, %v), want (%q, %v)", c.in, got, ok, c.want, c.ok)
}
}
}
// recHas reports whether the recorder saw an event type.
func recHas(r *eventRecorder, name string) bool {
for _, e := range r.events {
if e == name {
return true
}
}
return false
}
+250
View File
@@ -0,0 +1,250 @@
package run_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/executus/run"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// salvageNoopTool is an always-succeeds tool injected via Invocation.ExtraTools
// so a scripted tool-call loop advances (and counts a real tool call) without
// tripping the tool-error guard.
func salvageNoopTool() []llm.Tool {
return []llm.Tool{{
Name: "noop",
Description: "no-op",
Handler: func(context.Context, json.RawMessage) (any, error) { return "ok", nil },
}}
}
// narratingLoop returns a fake provider whose every reply NARRATES a step and
// calls noop with DISTINCT args (so the same-call-repeat guard never trips), so
// the loop never finalizes and runs until its step / tool-call budget. Each
// step's narration is recoverable by the salvage path.
func narratingLoop() *fake.Provider {
var mu sync.Mutex
var n int
return fake.New("fake", fake.WithDefault(func(_ string, _ llm.Request) fake.Step {
mu.Lock()
n++
step := n
mu.Unlock()
return fake.ReplyWith(llm.Response{
Parts: []llm.Part{llm.Text(fmt.Sprintf("reasoning %d: partial finding %d", step, step))},
ToolCalls: []llm.ToolCall{{
ID: fmt.Sprintf("c%d", step),
Name: "noop",
Arguments: []byte(fmt.Sprintf(`{"n":%d}`, step)),
}},
})
}))
}
// silentLoop is like narratingLoop but writes NO prose — so there is nothing for
// the salvage path to recover.
func silentLoop() *fake.Provider {
var mu sync.Mutex
var n int
return fake.New("fake", fake.WithDefault(func(_ string, _ llm.Request) fake.Step {
mu.Lock()
n++
step := n
mu.Unlock()
return fake.ReplyWith(llm.Response{
ToolCalls: []llm.ToolCall{{
ID: fmt.Sprintf("c%d", step),
Name: "noop",
Arguments: []byte(fmt.Sprintf(`{"n":%d}`, step)),
}},
})
}))
}
func modelsFor(m llm.Model) run.ModelResolver {
return func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil }
}
// TestSalvageSingleLoop_OnRecoversPartialReasoning: with the salvage default ON,
// a single-loop run that exhausts its step budget without a final answer is
// downgraded to a successful PARTIAL result carrying its step narration, instead
// of returning empty output + a hard ErrMaxSteps.
func TestSalvageSingleLoop_OnRecoversPartialReasoning(t *testing.T) {
fp := narratingLoop()
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
Defaults: run.Defaults{SalvageSingleLoopMaxSteps: true},
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "researcher", ModelTier: "m", MaxIterations: 3},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"do research")
if res.Err != nil {
t.Fatalf("salvage ON: budget exhaustion should downgrade to success; got err %v", res.Err)
}
if !strings.Contains(res.Output, "partial finding") {
t.Errorf("salvaged output should contain the step narration; got %q", res.Output)
}
if !strings.Contains(res.Output, "step budget") {
t.Errorf("salvaged output should carry the partial-answer note; got %q", res.Output)
}
}
// TestSalvageSingleLoop_OffKeepsHardError: with salvage OFF (the zero-value
// default), the same run returns empty output + a hard ErrMaxSteps — the
// structured-output host's clean failure.
func TestSalvageSingleLoop_OffKeepsHardError(t *testing.T) {
fp := narratingLoop()
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
// Defaults zero => SalvageSingleLoopMaxSteps false.
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 3},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"go")
if !errors.Is(res.Err, agent.ErrMaxSteps) {
t.Fatalf("salvage OFF: expected ErrMaxSteps, got %v", res.Err)
}
if strings.TrimSpace(res.Output) != "" {
t.Errorf("salvage OFF: output should be empty; got %q", res.Output)
}
}
// TestSalvageSingleLoop_NoProseStillErrors: salvage recovers nothing when the
// loop wrote no prose, so a hard error stands even with salvage ON.
func TestSalvageSingleLoop_NoProseStillErrors(t *testing.T) {
fp := silentLoop()
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
Defaults: run.Defaults{SalvageSingleLoopMaxSteps: true},
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 3},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"go")
if !errors.Is(res.Err, agent.ErrMaxSteps) {
t.Fatalf("no prose to salvage: ErrMaxSteps should stand, got %v", res.Err)
}
if strings.TrimSpace(res.Output) != "" {
t.Errorf("output should be empty; got %q", res.Output)
}
}
// TestMaxToolCalls_ForcesExitAndSurfacesSentinel: a run capped at 2 tool calls
// stops after the 2nd (well before its high step ceiling) and surfaces the
// distinct ErrMaxToolCalls sentinel.
func TestMaxToolCalls_ForcesExitAndSurfacesSentinel(t *testing.T) {
fp := narratingLoop()
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
// salvage OFF so the raw sentinel is observable.
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 50, MaxToolCalls: 2},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"go")
if !errors.Is(res.Err, run.ErrMaxToolCalls) {
t.Fatalf("expected ErrMaxToolCalls, got %v", res.Err)
}
// It must NOT read as a plain step-budget exhaustion.
if !strings.Contains(res.Err.Error(), "max tool calls") {
t.Errorf("error should name the tool-call cap; got %q", res.Err.Error())
}
}
// TestMaxToolCalls_SalvagedWhenEnabled: a tool-call-cap exit is treated as budget
// exhaustion, so with salvage ON it recovers the partial reasoning too.
func TestMaxToolCalls_SalvagedWhenEnabled(t *testing.T) {
fp := narratingLoop()
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
Defaults: run.Defaults{SalvageSingleLoopMaxSteps: true},
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 50, MaxToolCalls: 2},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"go")
if res.Err != nil {
t.Fatalf("cap + salvage: expected downgraded success, got %v", res.Err)
}
if !strings.Contains(res.Output, "partial finding") {
t.Errorf("expected salvaged narration, got %q", res.Output)
}
}
// TestMaxToolCalls_ZeroIsUnbounded: MaxToolCalls <= 0 imposes no ceiling — a run
// that makes several tool calls then finalizes completes normally (regression:
// unlimited must be a true no-op).
func TestMaxToolCalls_ZeroIsUnbounded(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "noop", Arguments: []byte(`{"n":1}`)}}}),
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c2", Name: "noop", Arguments: []byte(`{"n":2}`)}}}),
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c3", Name: "noop", Arguments: []byte(`{"n":3}`)}}}),
fake.Reply("final answer after 3 tools"),
)
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 10, MaxToolCalls: 0},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"go")
if res.Err != nil {
t.Fatalf("unbounded run should complete: %v", res.Err)
}
if res.Output != "final answer after 3 tools" {
t.Errorf("output = %q, want the final answer", res.Output)
}
}
// TestMaxToolCalls_CapOverridesCriticRaise: the tool-call cap is a HARD budget —
// it stops the run even when a critic is willing to raise the step ceiling far
// past it. Also proves stepCeilingOption composes with a critic.
func TestMaxToolCalls_CapOverridesCriticRaise(t *testing.T) {
h := &fakeCriticHandle{maxSteps: 50}
fp := narratingLoop()
m, _ := fp.Model("m")
ex := run.New(run.Config{
Registry: tool.NewRegistry(),
Models: modelsFor(m),
Ports: run.Ports{Critic: &fakeCritic{h: h}},
})
res := ex.Run(context.Background(),
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 1, MaxToolCalls: 2, Critic: run.CriticConfig{Enabled: true}},
tool.Invocation{RunID: "r", ExtraTools: salvageNoopTool()},
"go")
if !errors.Is(res.Err, run.ErrMaxToolCalls) {
t.Fatalf("the tool-call cap must override the critic's raised ceiling; got %v", res.Err)
}
}
+15
View File
@@ -19,6 +19,7 @@ package tool
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
@@ -238,6 +239,20 @@ type Invocation struct {
// persisted agent row. Skill runs ignore this field.
SystemPromptPrepend string
// ResultSchema, when non-empty, is a JSON Schema (draft 2020-12) the
// run's final answer must conform to. The executor validates the
// single-loop final output against it and, on violation, runs up to
// two bounded corrective rounds on the same conversation so the
// model self-repairs with full task context; a conforming answer is
// replaced by the bare validated JSON document. The kernel is
// FAIL-OPEN on schema problems (an uncompilable schema logs a WARN
// and skips validation — the host's own pre-dispatch compile is the
// authoritative gate) and best-effort on persistent violations (the
// last output stands; hosts keep their own fallback validator /
// error envelope). Multi-phase runs are not validated, matching
// FinalGuard's single-loop-only scope.
ResultSchema json.RawMessage
// SuppressDelivery, when true, instructs the skill executor to
// SKIP its OutputTarget Delivery (Deliver / DeliverError) entirely.
// The run still produces an output string (returned from Run) and