diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 1f4263f00..f4f5fd0bf 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -714,7 +714,12 @@ export async function runExec(config: Config): Promise { }); const liveSubAgentProvider: { current: SubAgentProvider } = { - current: buildSubAgentProvider(config), + current: buildSubAgentProvider({ + ...config, + ...(config.reasoningEffort !== undefined + ? { explicitReasoningEffort: true } + : {}), + }), }; const fleetSessions = createSubAgentSessionStore({ admission: getProcessAdmissionQueue(), diff --git a/src/provider/reasoning-effort.test.ts b/src/provider/reasoning-effort.test.ts index 390cdba1d..072a215aa 100644 --- a/src/provider/reasoning-effort.test.ts +++ b/src/provider/reasoning-effort.test.ts @@ -379,6 +379,61 @@ describe("pickEffortFromCascade (precedence table)", () => { }), ).toBeUndefined(); }); + + test("6. explicit parent outranks role default (CL-10227)", () => { + expect( + pickEffortFromCascade({ + roleDefault: "medium", + parentEffort: "none", + explicitParentEffort: true, + supported: ["none", "low", "medium", "high"], + }), + ).toBe("none"); + }); + + test("6b. explicit parent wins even when role default is supported", () => { + expect( + pickEffortFromCascade({ + roleDefault: "medium", + parentEffort: "high", + explicitParentEffort: true, + supported: ["low", "medium", "high"], + }), + ).toBe("high"); + }); + + test("6c. unsupported explicit parent is clamped onto supported", () => { + expect( + pickEffortFromCascade({ + roleDefault: "medium", + parentEffort: "xhigh", + explicitParentEffort: true, + supported: ["low", "medium", "high"], + }), + ).toBe("high"); + }); + + test("7. explicit pin still wins over explicit parent", () => { + expect( + pickEffortFromCascade({ + pin: "low", + roleDefault: "medium", + parentEffort: "xhigh", + explicitParentEffort: true, + supported: ["low", "medium", "high"], + }), + ).toBe("low"); + }); + + test("8. unset explicitParentEffort keeps role default above parent (CL-5162)", () => { + expect( + pickEffortFromCascade({ + roleDefault: "medium", + parentEffort: "high", + supported: ["low", "medium", "high"], + }), + ).toBe("medium"); + }); }); describe("resolveEffortForRole", () => { @@ -455,6 +510,50 @@ describe("resolveEffortForRole", () => { }), ).toBe("high"); }); + + test("explicit parent none wins over leaf role default (CL-10227)", () => { + expect( + resolveEffortForRole({ + orchestrator: false, + parentEffort: "none", + explicitParentEffort: true, + model: "gpt-5.1", + }), + ).toBe("none"); + }); + + test("explicit parent overrides orchestrator high default", () => { + expect( + resolveEffortForRole({ + orchestrator: true, + parentEffort: "low", + explicitParentEffort: true, + model: "gpt-5", + }), + ).toBe("low"); + }); + + test("unset parent keeps leaf role default (CL-5162)", () => { + expect( + resolveEffortForRole({ + orchestrator: false, + parentEffort: "high", + model: "gpt-5", + }), + ).toBe("medium"); + }); + + test("explicit pin wins over explicit parent", () => { + expect( + resolveEffortForRole({ + orchestrator: false, + pin: "low", + parentEffort: "high", + explicitParentEffort: true, + model: "gpt-5", + }), + ).toBe("low"); + }); }); describe("defaultEffortForModel", () => { diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index b9ac32a88..7be0b58e0 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -382,6 +382,8 @@ export interface ResolveEffortForRoleOpts { roleDefault?: ReasoningEffort; /** Parent session effort — used only when the role default is not supported. */ parentEffort?: ReasoningEffort; + /** Marker when the parent effort was operator-chosen (see SubAgentProvider). */ + explicitParentEffort?: true; model: string; isCodex?: boolean; providerEfforts?: readonly ReasoningEffort[]; @@ -393,10 +395,12 @@ export interface ResolveEffortForRoleOpts { * * Precedence (first match wins): * 1. Explicit pin (clamped onto supported when the pin is not in the set) - * 2. Role default when present in `supported` - * 3. Parent effort when present in `supported` - * 4. Clamp of role default onto `supported` - * 5. undefined when `supported` is empty + * 2. Explicit parent effort (operator-chosen fleet pin) when present in + * `supported` — clamped onto the set when not (CL-10227) + * 3. Role default when present in `supported` + * 4. Parent effort when present in `supported` + * 5. Clamp of role default onto `supported` + * 6. undefined when `supported` is empty * * Pins are still highest precedence, but an unsupported pin is clamped so the * pure API owns the "never emit an unsupported effort" invariant (callers that @@ -406,6 +410,7 @@ export function pickEffortFromCascade(opts: { pin?: ReasoningEffort; roleDefault: ReasoningEffort; parentEffort?: ReasoningEffort; + explicitParentEffort?: true; supported: readonly ReasoningEffort[]; }): ReasoningEffort | undefined { if (opts.supported.length === 0) return undefined; @@ -414,6 +419,13 @@ export function pickEffortFromCascade(opts: { ? opts.pin : clampEffort(opts.pin, opts.supported); } + // An explicit (operator-chosen) parent effort is a fleet-wide pin: it beats + // the role default so a primary cycled to none really gives none workers. + if (opts.explicitParentEffort === true && opts.parentEffort !== undefined) { + return opts.supported.includes(opts.parentEffort) + ? opts.parentEffort + : clampEffort(opts.parentEffort, opts.supported); + } if (opts.supported.includes(opts.roleDefault)) return opts.roleDefault; if ( opts.parentEffort !== undefined && @@ -427,10 +439,13 @@ export function pickEffortFromCascade(opts: { /** * Resolve reasoning effort for a sub-agent spawn. * - * Why parent is below role default: a /agent high selection on the primary must - * not force every leaf onto high — that multiplies the sol+high latency cliff - * across the fleet. Parent still fills gaps when the role default is not in the - * model's supported set but the parent effort is. + * Precedence: explicit pin > explicit parent (operator-chosen fleet pin) > role + * default > derived parent > clamp. An explicit primary effort — including + * `none`/off — is fleet-wide, so it beats both the leaf (medium) and the + * orchestrator (high) role default. The unset parent case keeps CL-5162: the + * role default outranks a derived parent so a /agent high selection on the + * primary does not force every leaf onto high (which would multiply the + * sol+high latency cliff across the fleet). */ export function resolveEffortForRole( opts: ResolveEffortForRoleOpts, @@ -452,6 +467,9 @@ export function resolveEffortForRole( ...(opts.parentEffort !== undefined ? { parentEffort: opts.parentEffort } : {}), + ...(opts.explicitParentEffort === true + ? { explicitParentEffort: true } + : {}), supported, }); } diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 324a08475..6ab5cedfe 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -79,6 +79,42 @@ describe("buildSubAgentProvider", () => { bifrostVirtualKey: true, }); }); + + test("omits explicitReasoningEffort when not operator-chosen", () => { + expect( + buildSubAgentProvider({ + providerName: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-5", + reasoningEffort: "medium", + providers: [{ name: "openai" }], + }), + ).toEqual({ + providerName: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-5", + reasoningEffort: "medium", + }); + }); + + test("threads explicitReasoningEffort when operator-chosen", () => { + expect( + buildSubAgentProvider({ + providerName: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-5", + reasoningEffort: "none", + explicitReasoningEffort: true, + providers: [{ name: "openai" }], + }), + ).toEqual({ + providerName: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-5", + reasoningEffort: "none", + explicitReasoningEffort: true, + }); + }); }); describe("createLiveSubAgentSources", () => { diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index d6c9e3805..1c29b1f4f 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -76,6 +76,8 @@ export interface SubAgentProviderConfig { apiKey?: string; model: string; reasoningEffort?: ReasoningEffort; + /** Present when operator-chosen — see SubAgentProvider.explicitReasoningEffort. */ + explicitReasoningEffort?: true; providers: readonly Pick< ProviderCatalogEntry, "name" | "bifrostVirtualKey" @@ -94,6 +96,9 @@ export function buildSubAgentProvider( ...(config.reasoningEffort !== undefined ? { reasoningEffort: config.reasoningEffort } : {}), + ...(config.explicitReasoningEffort === true + ? { explicitReasoningEffort: true } + : {}), ...(config.providers.find((p) => p.name === config.providerName) ?.bifrostVirtualKey === true ? { bifrostVirtualKey: true } diff --git a/src/subagent/agent-fleet-effort.test.ts b/src/subagent/agent-fleet-effort.test.ts index 94f5e51bc..cdd708931 100644 --- a/src/subagent/agent-fleet-effort.test.ts +++ b/src/subagent/agent-fleet-effort.test.ts @@ -166,4 +166,187 @@ describe("custom worker efforts", () => { expect(effort).toBe("medium"); }, ); + + // CL-10227: an explicit (operator-chosen) primary effort is a fleet-wide pin + // that overrides the role default for spawned workers. + const explicitProvider: ProviderCatalogEntry = { + ...provider, + reasoningEfforts: ["none", "high", "max"], + }; + test.each([ + { parent: "none" as const, expected: "none" }, + { parent: "high" as const, expected: "high" }, + ])( + "explicit parent %j pins the worker effort", + async ({ parent, expected }) => { + let worker: RunSubAgentParams | undefined; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { + settings: { providers: { custom: explicitProvider } }, + catalog: [explicitProvider], + }, + ); + deps.provider = { + providerName: explicitProvider.name, + baseURL: explicitProvider.baseURL, + model: "custom-model", + reasoningEffort: parent, + explicitReasoningEffort: true, + }; + const result = await callFleetToolRaw(createSpawnAgentTool(deps), { + description: "inspect", + prompt: "inspect", + intent: "explore", + }); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + expect(worker?.provider.reasoningEffort).toBe(expected); + }, + ); + + test("clamped explicit parent drops the explicit marker (CL-10227)", async () => { + // An explicit parent `none` on a model whose ladder rejects `none` is + // clamped onto the supported set (low). The clamped value is derived, not + // operator-chosen, so the explicit marker must not survive — otherwise the + // worker would carry a fleet-wide explicit pin it never actually received. + let worker: RunSubAgentParams | undefined; + const noNoneProvider: ProviderCatalogEntry = { + ...provider, + reasoningEfforts: ["low", "high", "max"], + }; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { + settings: { providers: { custom: noNoneProvider } }, + catalog: [noNoneProvider], + }, + ); + deps.provider = { + providerName: noNoneProvider.name, + baseURL: noNoneProvider.baseURL, + model: "custom-model", + reasoningEffort: "none", + explicitReasoningEffort: true, + }; + const result = await callFleetToolRaw(createSpawnAgentTool(deps), { + description: "inspect", + prompt: "inspect", + intent: "explore", + }); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + expect(worker?.provider.reasoningEffort).toBe("low"); + // The clamped value is derived, so the flag is dropped (absent), not `true`. + expect(worker?.provider.explicitReasoningEffort).toBeUndefined(); + }); + + test("explicit parent pin survives the applyResolvedProvider rebuild (CL-10227)", async () => { + // A profile/director spawn with a resolving inference rebuilds `provider` + // from settings via applyResolvedProvider. That rebuild must not drop the + // operator-explicit parent-effort marker — otherwise the operator pin is + // demoted to a derived parent and the leaf role default (medium) wins, + // violating the CL-10227 fleet-pin contract. Here the model ladder supports + // both the parent ("none") and the role default ("medium"), so only the + // surviving explicit flag can pick "none". + let worker: RunSubAgentParams | undefined; + const pinProvider: ProviderCatalogEntry = { + ...provider, + reasoningEfforts: ["none", "medium", "max"], + }; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { + settings: { providers: { custom: pinProvider } }, + catalog: [pinProvider], + profiles: [ + { + id: "custom-leaf", + inference: { + mode: "pin", + order: [ + { + provider: pinProvider.name, + model: "custom-model", + }, + ], + }, + }, + ], + }, + ); + deps.provider = { + providerName: pinProvider.name, + baseURL: pinProvider.baseURL, + model: "custom-model", + reasoningEffort: "none", + explicitReasoningEffort: true, + }; + const result = await callFleetToolRaw(createSpawnAgentTool(deps), { + description: "inspect", + prompt: "inspect", + agent: "custom-leaf", + }); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + // The operator-explicit parent pin ("none") survives the rebuild and beats + // the leaf role default ("medium"). + expect(worker?.provider.reasoningEffort).toBe("none"); + expect(worker?.provider.explicitReasoningEffort).toBe(true); + }); + + test("explicit effortPin still wins over an explicit parent", async () => { + // An explicit parent is a fleet pin, but the cascade still lets an explicit + // effortPin outrank it — the legacy pin-max case stays. + let worker: RunSubAgentParams | undefined; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { + settings: { providers: { custom: explicitProvider } }, + catalog: [explicitProvider], + profiles: [ + { + id: "custom-leaf", + inference: { + mode: "pin", + order: [ + { + provider: explicitProvider.name, + model: "custom-model", + reasoningEffort: "max", + }, + ], + }, + }, + ], + }, + ); + deps.provider = { + providerName: explicitProvider.name, + baseURL: explicitProvider.baseURL, + model: "custom-model", + reasoningEffort: "none", + explicitReasoningEffort: true, + }; + const result = await callFleetToolRaw(createSpawnAgentTool(deps), { + description: "inspect", + prompt: "inspect", + agent: "custom-leaf", + }); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + expect(worker?.provider.reasoningEffort).toBe("max"); + }); }); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index f1701fb33..cb36e65f1 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -1015,6 +1015,11 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { let provider: SubAgentProvider = resolveDep(deps.provider); const parentEffort = provider.reasoningEffort; + // Operator-chosen parent effort signal, captured BEFORE applyResolvedProvider + // may rebuild `provider` from settings (which drops any marker). Read from + // this captured value everywhere so the explicit fleet-pin survives the + // rebuild; `true` is the only value the type allows. + const parentExplicit = provider.explicitReasoningEffort === true; const diskSettings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; const catalog = @@ -1067,6 +1072,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { ? { apiKey: providerSettings.apiKey } : {}), model: resolved.model, + // The operator-chosen parent-effort marker is not a provider fact: + // it survived the rebuild so an effort-carrying director/profile + // spawn keeps its explicit fleet-pin signal (re-read at resolve time). + ...(parentExplicit ? { explicitReasoningEffort: true } : {}), }; return null; }; @@ -1183,6 +1192,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { ? { roleDefault: resolved.roleDefault } : {}), ...(parentEffort !== undefined ? { parentEffort } : {}), + ...(parentEffort !== undefined && parentExplicit + ? { explicitParentEffort: true } + : {}), model: provider.model, isCodex: isCodexProviderName(provider.providerName), ...(reasoning?.reasoningEfforts !== undefined @@ -1190,9 +1202,31 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { : {}), }); if (effort !== undefined) { - provider = { ...provider, reasoningEffort: effort }; + // The explicit marker travels only when the re-resolved effort is + // exactly the operator-chosen parent value. Clamping an explicit + // parent effort onto the supported set yields a derived (not + // operator-chosen) value, so the flag is dropped — a clamped value + // must not mislabel itself as a fleet-wide explicit pin for every + // descendant. `true` is the only value the type allows. + const carriedExplicit = + provider.explicitReasoningEffort === true && + parentEffort !== undefined && + effort === parentEffort; + provider = { + ...provider, + reasoningEffort: effort, + ...(carriedExplicit ? { explicitReasoningEffort: true } : {}), + }; + if (!carriedExplicit) { + const { explicitReasoningEffort: _drop, ...rest } = provider; + provider = rest; + } } else { - const { reasoningEffort: _drop, ...rest } = provider; + const { + reasoningEffort: _drop, + explicitReasoningEffort: _dropExplicit, + ...rest + } = provider; provider = rest; } diff --git a/src/subagent/types.ts b/src/subagent/types.ts index a0ad433b7..1b4f69ed4 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -28,10 +28,18 @@ export interface SubAgentProvider { apiKey?: string; keyless?: boolean; model: string; - // Resolved effort for this spawn (pin > role default > parent). See - // resolveEffortForRole — leaves default to medium, orchestrators to high, - // so a primary /agent high selection does not force every leaf onto high. + // Resolved effort for this spawn (pin > explicit parent > role default > + // derived parent). See resolveEffortForRole — leaves default to medium, + // orchestrators to high; an operator-chosen (explicit) primary effort is a + // fleet-wide pin and beats the role default. reasoningEffort?: ReasoningEffort; + /** + * Present (true) when `reasoningEffort` was operator-chosen (Config sent an + * explicit reasoningEffort) rather than derived. A present marker is a + * fleet-wide pin that outranks the role default; absent = role defaults + * apply (CL-5162). + */ + explicitReasoningEffort?: true; // Mirrors ProviderCatalogEntry.bifrostVirtualKey. Without it the dispatch // path builds a plain openai-compatible source and the gateway never // receives the x-bf-vk header. diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index e4dc2b29e..8271e8cc6 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -72,6 +72,7 @@ import { createSessionPruningCompactor, loadSessionChatPrompt, skillDirsFromEnabledPlugins, + type SubAgentSourcesConfig, } from "../../session/runtime-assembly.js"; import { createModelSummarizer, @@ -227,7 +228,12 @@ export async function assembleTUISession( // live config binding on every spawn, so every switch path that reassigns // config (model picker, /agent, post-connect refresh) is picked up without // a separate cache to keep in sync. - const liveSubAgent = createLiveSubAgentSources(() => state.config); + const liveSubAgent = createLiveSubAgentSources((): SubAgentSourcesConfig => ({ + ...state.config, + ...(state.config.reasoningEffort !== undefined + ? { explicitReasoningEffort: true } + : {}), + })); // Dedicated child-session records for enter-session inspection. Child events // land here only — never in the parent chat transcript.