diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 8f5094c..7284033 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -28,6 +28,11 @@ type Verifier struct { // the shared picker (pick.ts) over the shared Pcg. nextActionFn goja.Callable + // setupActionFn is the bundle-installed __sanderlingSetupAction__, which + // walks ONLY the setup generator. The LLM action generator runs it for setup + // precedence (e.g. login) without triggering the seeded action root. + setupActionFn goja.Callable + // sampleInputFn is the bundle-installed __sanderlingSampleInput__, which // draws one value from the shared INPUT_CORPUS. The LLM action backend uses // it to fill InputText values, reusing the exact corpus draw rather than @@ -158,6 +163,11 @@ func (v *Verifier) Load(source string) error { v.nextActionFn = callable } } + if fn := v.runtime.GlobalObject().Get("__sanderlingSetupAction__"); fn != nil { + if callable, ok := goja.AssertFunction(fn); ok { + v.setupActionFn = callable + } + } // __sanderlingSampleInput__ draws an InputText value from the shared corpus. // The LLM action backend uses it; a raw-JS fixture without the runtime entry @@ -596,6 +606,28 @@ func (v *Verifier) NextAction() (Action, error) { return DecodeAction(raw) } +// SetupAction walks ONLY the setup generator (globalThis.setup), returning its +// action or ErrNoAction. The LLM action generator runs this for setup +// precedence (e.g. login) without triggering the seeded action root, which it +// replaces entirely. Mirrors NextAction's decode. +func (v *Verifier) SetupAction() (Action, error) { + if v.setupActionFn == nil { + return Action{}, ErrNoAction + } + value, err := v.setupActionFn(goja.Undefined()) + if err != nil { + return Action{}, fmt.Errorf("setup action: %w", err) + } + if value == nil || goja.IsNull(value) || goja.IsUndefined(value) { + return Action{}, ErrNoAction + } + raw, err := json.Marshal(value.Export()) + if err != nil { + return Action{}, fmt.Errorf("marshal action: %w", err) + } + return DecodeAction(raw) +} + var ErrNoAction = errors.New("verifier: no action available") func (v *Verifier) formulaThunk(index int) func() (bool, error) { diff --git a/pkg/spec/src/runtime-entry.ts b/pkg/spec/src/runtime-entry.ts index 6f8377a..95b0293 100644 --- a/pkg/spec/src/runtime-entry.ts +++ b/pkg/spec/src/runtime-entry.ts @@ -139,6 +139,16 @@ export function installRuntime( () => INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "", ); defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors()); + // __sanderlingSetupAction__ walks ONLY the setup generator once, for the LLM + // action generator (Go), which drives selection itself and must not run the + // seeded action root — but still wants setup's precondition steps (e.g. login) + // to run first. Returns null when setup is unset or yields nothing. + defineLockedGlobal("__sanderlingSetupAction__", () => { + resolveRoot(); + const setup = resolveSetup(); + if (!setup) return null; + return serializeAction(walk(setup, rng, host)); + }); defineLockedGlobal("__sanderlingNextAction__", () => { // resolveRoot runs first: on web it also resets the per-tick candidate // cache, which setup's walk below must see fresh.