diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index 7f560497da254..1bb050ff734cb 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -8,7 +8,7 @@ import { type RuntimeSubagent, type SubagentWorkflowGroup, } from "@t3tools/client-runtime/state/subagentRuntime"; -import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; +import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/shared/model"; import type { EnvironmentId, OrchestrationV2Subagent, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 08bcb9f8a7608..1d7e695fef742 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1326,14 +1326,18 @@ const make = Effect.gen(function* () { }) .pipe(Effect.mapError(threadManagementFailure)); - /** An orchestrator's children default to the worker model, so its own model is not the one to inherit. */ + /** An orchestrator's children default to the worker model and effort, not to its own. */ const withWorkerModel = Effect.fn("OrchestratorMcpService.withWorkerModel")(function* < Parent extends Pick, >(parent: Parent) { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); - const modelSelection = orchestratorWorkerModelSelection(settings, parent.thread); + const modelSelection = orchestratorWorkerModelSelection( + settings, + parent.thread, + yield* loadProviders, + ); return modelSelection === undefined ? parent : { ...parent, thread: { ...parent.thread, modelSelection } }; @@ -2358,7 +2362,8 @@ const make = Effect.gen(function* () { Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); const inherited = - orchestratorWorkerModelSelection(settings, parent?.thread) ?? parent?.thread.modelSelection; + orchestratorWorkerModelSelection(settings, parent?.thread, providers) ?? + parent?.thread.modelSelection; const searchTerms = input.search?.toLowerCase().split(/\s+/).filter(Boolean) ?? []; return { parentThreadId: parent?.thread.id ?? null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6daf189f7d6df..558315cc10303 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3308,6 +3308,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThread.id} isServerThread={isServerThread} activeThreadTitle={activeThread.title} + activeThreadModelSelection={activeThread.modelSelection} activeThreadLinkOrigin={activeThreadShell?.linkOrigin?.label ?? null} activeProject={activeProject ?? null} parentThreadLink={headerParentThreadLink} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 20dc5ea531ec3..37baf35afb279 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, + type ModelSelection, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; @@ -48,6 +49,7 @@ interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; activeThreadTitle: string; + activeThreadModelSelection?: ModelSelection | undefined; activeThreadLinkOrigin?: string | null | undefined; isServerThread: boolean; activeProject: EnvironmentProject | null; @@ -73,6 +75,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, activeThreadTitle, + activeThreadModelSelection, activeThreadLinkOrigin, isServerThread, activeProject, @@ -320,7 +323,12 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - + {renamingTitle !== null ? ( + choices.find((choice) => choice.id === effort)?.label ?? "Model default"; + const own = + settings.orchestratorThreadWorkerEfforts.find((entry) => entry.threadId === props.threadId) + ?.effort ?? null; return ( - - + } > - - - Orchestrator mode: plans the work and hands it to workers - - + + + + Subagent effort in this chat + + void setOrchestratorWorkerEffort( + props.environmentId, + props.threadId, + value === DEFAULT_EFFORT ? null : value, + ) + } + > + + + Default + {labelOf(settings.orchestratorWorkerEffort)} + + + {choices.map((choice) => ( + + {choice.label} + + ))} + + + + ); } diff --git a/apps/web/src/components/settings/OrchestratorSettings.tsx b/apps/web/src/components/settings/OrchestratorSettings.tsx new file mode 100644 index 0000000000000..b3d5292ab02f3 --- /dev/null +++ b/apps/web/src/components/settings/OrchestratorSettings.tsx @@ -0,0 +1,95 @@ +import type { ProviderOptionChoice, ServerProvider } from "@t3tools/contracts"; +import { + orchestratorEffortChoices, + orchestratorWorkerEffortChoices, +} from "@t3tools/shared/orchestratorMode"; + +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; + +const MODEL_DEFAULT = "model-default"; + +function EffortSelect(props: { + label: string; + value: string | null; + mixed: boolean; + choices: ReadonlyArray; + onChange: (effort: string | null) => void; +}) { + const labelOf = (value: string | null) => + value === null + ? "Mixed" + : value === MODEL_DEFAULT + ? "Model default" + : (props.choices.find((choice) => choice.id === value)?.label ?? value); + return ( + + ); +} + +/** The efforts new orchestrators and their subagents start on. */ +export function OrchestratorSettings({ providers }: { providers: ReadonlyArray }) { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const mixedEffort = useScopedSettingsMixed(["orchestratorEffort"]); + const mixedWorkerEffort = useScopedSettingsMixed(["orchestratorWorkerEffort"]); + return ( + + updateSettings({ orchestratorEffort: effort })} + /> + } + /> + updateSettings({ orchestratorWorkerEffort: effort })} + /> + } + /> + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a37bf1581990e..ad7e505cf53af 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -59,6 +59,7 @@ import { import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; import { scopedIdleCompaction } from "./scopedSettings"; +import { OrchestratorSettings } from "./OrchestratorSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; import { SnoozePresetsRow } from "./SnoozePresetsSettings"; @@ -1649,6 +1650,8 @@ export function ThreadsSettingsPanel() { ) : null} + {!isProjectScope ? : null} + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId); -const warnNotSaved = (description: string) => - toastManager.add({ type: "warning", title: "Orchestrator mode not changed", description }); - -/** Turns orchestrator mode on or off for threads of one environment. False when nothing was saved. */ -async function setOrchestratorMode( +/** Saves orchestrator settings of one environment, with a toast titled `failure` when it cannot. */ +async function saveOrchestratorSettings( environmentId: EnvironmentId, - changes: ReadonlyArray, + failure: string, + makePatch: (settings: ServerSettings) => ServerSettingsPatch | null, ): Promise { + const warnNotSaved = (description: string) => + toastManager.add({ type: "warning", title: failure, description }); const config = readServerConfig(environmentId); if (config === undefined) { warnNotSaved("This environment is not connected"); @@ -53,14 +55,7 @@ async function setOrchestratorMode( ); return false; } - let patch: ServerSettingsPatch | null = null; - let threads = config.settings.orchestratorThreads; - for (const change of changes) { - const next = orchestratorThreadsPatch(threads, change.threadId, change.on); - if (next?.orchestratorThreads === undefined) continue; - patch = next; - threads = next.orchestratorThreads; - } + const patch = makePatch(config.settings); if (patch === null) return true; if ( !requiredScopesForServerSettingsPatch(patch).every((scope) => @@ -86,9 +81,38 @@ async function setOrchestratorMode( return false; } +/** Turns orchestrator mode on or off for threads of one environment. False when nothing was saved. */ +const setOrchestratorMode = ( + environmentId: EnvironmentId, + changes: ReadonlyArray, +): Promise => + saveOrchestratorSettings(environmentId, "Orchestrator mode not changed", (settings) => { + let patch: ServerSettingsPatch | null = null; + let threads = settings.orchestratorThreads; + for (const change of changes) { + const next = orchestratorThreadsPatch(threads, change.threadId, change.on); + if (next?.orchestratorThreads === undefined) continue; + patch = next; + threads = next.orchestratorThreads; + } + return patch; + }); + +/** Sets the effort this orchestrator's subagents start on; null follows the default. */ +export const setOrchestratorWorkerEffort = ( + environmentId: EnvironmentId, + threadId: ThreadId, + effort: string | null, +): Promise => + saveOrchestratorSettings(environmentId, "Subagent effort not changed", (settings) => + orchestratorThreadWorkerEffortPatch(settings.orchestratorThreadWorkerEfforts, threadId, effort), + ); + /** A new orchestrator starts on the orchestrator model when a provider offers it. */ function applyOrchestratorModel(environmentId: EnvironmentId, draftId: DraftId) { - const selection = orchestratorModelSelection(readServerConfig(environmentId)?.providers ?? []); + const config = readServerConfig(environmentId); + if (config === undefined) return; + const selection = orchestratorModelSelection(config.providers, config.settings); if (selection === null) return; useComposerDraftStore.getState().setModelSelection(draftId, selection, { replaceOptions: true }); } diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index 48a1227429312..5030145dd28bb 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -14,7 +14,7 @@ import { import { isReasoningSelect } from "./modelTraits.ts"; import type { ThreadStructure } from "./state/models.ts"; -import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/shared/model"; export type HandoffTimelineRun = Pick< OrchestrationV2Run, diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 6e84acf10fc0d..4cefefd0af53c 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -5,7 +5,7 @@ import { getProviderOptionCurrentValue, isClaudeUltrathinkPrompt, } from "@t3tools/shared/model"; -import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/shared/model"; const REASONING_DESCRIPTOR_IDS: ReadonlySet = new Set([ ...REASONING_EFFORT_OPTION_IDS, diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 44600ffa259e0..31b1f0b85e7e6 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -31,6 +31,7 @@ import { getModelSelectionStringOptionValue, getProviderOptionCurrentLabel, getProviderOptionStatedValue, + REASONING_EFFORT_OPTION_IDS, resolveSelectableModel, withLegacyUltracodeEffort, } from "@t3tools/shared/model"; @@ -197,15 +198,6 @@ export function deriveReportedModelSelection(projection: ThreadStructure): Model return providerThread?.nativeMetadata?.modelSelection ?? null; } -export const REASONING_EFFORT_OPTION_IDS: ReadonlyArray = [ - "reasoningEffort", - "effort", - "reasoning", - "variant", - "reasoning_effort", - "thinking_level", -]; - export function formatModelSelectionEffort( selection: ModelSelection, models: ReadonlyArray = [], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 85d262c271659..109e65585ee20 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1340,6 +1340,11 @@ export const CloudSpeechCredentials = Schema.Struct({ openrouter: CloudSpeechCredential, }); +export const OrchestratorThreadWorkerEffort = Schema.Struct({ + threadId: ThreadId, + effort: TrimmedNonEmptyString, +}); + export const ServerSettings = Schema.Struct({ // Null means unmigrated; empty is authoritative and must not resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), @@ -1437,6 +1442,18 @@ export const ServerSettings = Schema.Struct({ ), /** Threads whose agent orchestrates: it plans and delegates to workers on the default model. */ orchestratorThreads: Schema.Array(ThreadId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Reasoning effort a new orchestrator starts on; null keeps its model's default. */ + orchestratorEffort: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** Reasoning effort an orchestrator's workers start on; null keeps the worker model's own. */ + orchestratorWorkerEffort: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** Orchestrators whose workers start on another effort than `orchestratorWorkerEffort`. */ + orchestratorThreadWorkerEfforts: Schema.Array(OrchestratorThreadWorkerEffort).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), snoozePresets: SnoozePresetSettings.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_PRESETS)), ), @@ -1876,6 +1893,9 @@ export const ServerSettingsPatch = Schema.Struct({ autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), archiveScheduledTaskRuns: Schema.optionalKey(Schema.Array(ScheduledTaskId)), orchestratorThreads: Schema.optionalKey(Schema.Array(ThreadId)), + orchestratorEffort: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + orchestratorWorkerEffort: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + orchestratorThreadWorkerEfforts: Schema.optionalKey(Schema.Array(OrchestratorThreadWorkerEffort)), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozePresets: Schema.optionalKey(SnoozePresetSettings), hiddenThreadTags: Schema.optionalKey(Schema.Array(Schema.String)), diff --git a/packages/provider-core/src/server/orchestratorModeInstructions.ts b/packages/provider-core/src/server/orchestratorModeInstructions.ts index 1080157310160..0806b6f2c6f2c 100644 --- a/packages/provider-core/src/server/orchestratorModeInstructions.ts +++ b/packages/provider-core/src/server/orchestratorModeInstructions.ts @@ -14,8 +14,9 @@ export function orchestratorModeInstructions(input: { You are this project's orchestrator. The user talks to you; workers do the work. This section outranks the subagent rules above. - A worker is a \`delegate_task\` child. Without \`target\` it starts on ${workerModel}, the user's choice for workers: pass a model only when the user names one. No native subagent tool, and Workflow only when asked. +- The user tells you to do a task yourself: do it yourself, edits, builds and tests included. That covers the task they named, not the ones after it. - Yours, never delegated: what the user wants, the split into tasks, each decision between options, the verdict on a result, the report. Answer what needs no work in the repo yourself. -- Read files, threads and git state, and run a short proof command, to orient yourself and to check results. Edits, builds, long test runs and long investigation go to workers, small review fixes included: send those to the worker that wrote the code. +- Read files, threads and git state, and run a short proof command, to orient yourself and to check results. Otherwise edits, builds, long test runs and long investigation go to workers, small review fixes included: send those to the worker that wrote the code. - Split by independence. Start independent tasks in one message so they run in parallel. Writers share no files: one at a time, or \`isolation:"worktree"\` each. A task of a few tool calls is one worker, not a fan-out. - After starting workers, tell the user in a line what runs, then end your turn. Results wake you. Never predict one.`, `### Working with workers diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 0fdb2bc71ec4f..23927a413b76b 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -223,6 +223,16 @@ export function withLegacyUltracodeEffort< ]; } +/** Ids providers give their reasoning control, most specific first. */ +export const REASONING_EFFORT_OPTION_IDS: ReadonlyArray = [ + "reasoningEffort", + "effort", + "reasoning", + "variant", + "reasoning_effort", + "thinking_level", +]; + export function getProviderOptionDescriptors(input: { caps: ModelCapabilities; selections?: ReadonlyArray | null | undefined; diff --git a/packages/shared/src/orchestratorMode.test.ts b/packages/shared/src/orchestratorMode.test.ts index 264a31b3ff998..8a61889a9f79a 100644 --- a/packages/shared/src/orchestratorMode.test.ts +++ b/packages/shared/src/orchestratorMode.test.ts @@ -1,12 +1,17 @@ import { DEFAULT_SERVER_SETTINGS, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { orchestratorWorkerModelSelection } from "./orchestratorMode.ts"; +import { + orchestratorModelSelection, + orchestratorThreadWorkerEffortPatch, + orchestratorWorkerModelSelection, +} from "./orchestratorMode.ts"; import { applyServerSettingsPatch } from "./serverSettings.ts"; const projectId = ProjectId.make("project-a"); @@ -46,4 +51,66 @@ describe("orchestratorWorkerModelSelection", () => { ), ).toBeUndefined(); }); + + it("starts workers on the worker effort, this chat's own first, when their model offers it", () => { + const lead = ThreadId.make("lead"); + const effort = (id: string) => ({ id, label: id }); + const providers = [ + { + instanceId: defaultModelSelection.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + models: ["claude-opus-5-5", "claude-fable-5-2"].map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select" as const, + options: [effort("low"), effort("high")], + }, + ], + }, + })), + }, + ]; + const withDefault = { ...settings, orchestratorWorkerEffort: "low" }; + const worker = (current: typeof settings, thread = { id: lead, projectId }) => + orchestratorWorkerModelSelection(current, thread, providers); + + expect(worker(withDefault)).toEqual({ + ...defaultModelSelection, + options: [{ id: "effort", value: "low" }], + }); + expect( + worker({ + ...withDefault, + ...orchestratorThreadWorkerEffortPatch([], lead, "high"), + } as typeof settings), + ).toEqual({ ...defaultModelSelection, options: [{ id: "effort", value: "high" }] }); + // An effort the model does not offer must not reach the provider. + expect(worker({ ...settings, orchestratorWorkerEffort: "max" })).toEqual(defaultModelSelection); + // Without a default model the worker keeps the orchestrator's model and only the effort changes. + const own = { + instanceId: defaultModelSelection.instanceId, + model: "claude-fable-5-2", + options: [{ id: "effort", value: "high" }], + }; + expect( + orchestratorWorkerModelSelection( + { ...withDefault, defaultModelSelection: null }, + { id: lead, projectId, modelSelection: own }, + providers, + ), + ).toEqual({ ...own, options: [{ id: "effort", value: "low" }] }); + expect(orchestratorModelSelection(providers, { orchestratorEffort: "low" })).toEqual({ + instanceId: defaultModelSelection.instanceId, + model: "claude-fable-5-2", + options: [{ id: "effort", value: "low" }], + }); + }); }); diff --git a/packages/shared/src/orchestratorMode.ts b/packages/shared/src/orchestratorMode.ts index e471046f88331..9f87918c26984 100644 --- a/packages/shared/src/orchestratorMode.ts +++ b/packages/shared/src/orchestratorMode.ts @@ -1,12 +1,15 @@ import type { ModelSelection, ProjectId, + ProviderOptionChoice, ServerProvider, ServerSettings, + SelectProviderOptionDescriptor, ServerSettingsPatch, ThreadId, } from "@t3tools/contracts"; +import { REASONING_EFFORT_OPTION_IDS, resolveSelectableModel } from "./model.ts"; import { resolveProjectSettings } from "./projectSettings.ts"; /** The model a new orchestrator starts on when a provider offers it. */ @@ -22,23 +25,110 @@ export function isOrchestratorThread( return settings.orchestratorThreads.includes(threadId); } +type Providers = ReadonlyArray< + Pick +>; + +/** The reasoning control of a model, or undefined when the model has none or is unknown. */ +function reasoningEffortDescriptor( + providers: Providers, + selection: Pick, +): SelectProviderOptionDescriptor | undefined { + const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); + // A stored selection may name the model by alias or display name. + const slug = + provider && resolveSelectableModel(provider.driver, selection.model, provider.models); + const selects = ( + provider?.models.find((model) => model.slug === slug)?.capabilities?.optionDescriptors ?? [] + ).flatMap((descriptor) => (descriptor.type === "select" ? [descriptor] : [])); + return ( + selects.find((descriptor) => descriptor.role === "reasoning") ?? + REASONING_EFFORT_OPTION_IDS.flatMap((id) => + selects.filter((descriptor) => descriptor.id === id), + )[0] + ); +} + +/** The efforts a model can be started on; prompt-injected ones such as ultrathink are per message. */ +function reasoningEffortChoices( + providers: Providers, + selection: Pick, +): ReadonlyArray { + const descriptor = reasoningEffortDescriptor(providers, selection); + return ( + descriptor?.options.filter( + (option) => descriptor.promptInjectedValues?.includes(option.id) !== true, + ) ?? [] + ); +} + +/** `selection` on `effort`; unchanged when `effort` is null or its model does not offer it. */ +function withReasoningEffort( + providers: Providers, + selection: ModelSelection, + effort: string | null, +): ModelSelection { + if (effort === null) return selection; + const descriptor = reasoningEffortDescriptor(providers, selection); + if ( + descriptor === undefined || + !reasoningEffortChoices(providers, selection).some((option) => option.id === effort) + ) { + return selection; + } + return { + ...selection, + options: [ + ...(selection.options ?? []).filter((option) => option.id !== descriptor.id), + { id: descriptor.id, value: effort }, + ], + }; +} + +/** The effort an orchestrator's workers start on: this chat's own, else the default, else null. */ +function orchestratorWorkerEffort( + settings: Pick, + threadId: ThreadId, +): string | null { + return ( + settings.orchestratorThreadWorkerEfforts.find((entry) => entry.threadId === threadId)?.effort ?? + settings.orchestratorWorkerEffort + ); +} + /** - * The model an orchestrator's workers start on: the project's default model. Undefined when the - * thread is no orchestrator or no default is set, so a worker inherits its parent's model. + * What an orchestrator's workers start on: the project's default model, else the orchestrator's + * own, on the worker effort when `providers` shows the model offers it. Undefined when the thread + * is no orchestrator or nothing differs from its own selection, so a worker inherits that. */ export function orchestratorWorkerModelSelection( settings: ServerSettings, - thread: { readonly id: ThreadId; readonly projectId: ProjectId | null } | undefined, + thread: + | { + readonly id: ThreadId; + readonly projectId: ProjectId | null; + readonly modelSelection?: ModelSelection; + } + | undefined, + providers: Providers = [], ): ModelSelection | undefined { if (thread === undefined || !isOrchestratorThread(settings, thread.id)) return undefined; - return ( - resolveProjectSettings(settings, thread.projectId).settings.defaultModelSelection ?? undefined + const projectDefault = + resolveProjectSettings(settings, thread.projectId).settings.defaultModelSelection ?? undefined; + const base = projectDefault ?? thread.modelSelection; + if (base === undefined) return undefined; + const selection = withReasoningEffort( + providers, + base, + orchestratorWorkerEffort(settings, thread.id), ); + return projectDefault === undefined && selection === base ? undefined : selection; } /** The selection a new orchestrator starts on, or null to keep the new thread's usual model. */ export function orchestratorModelSelection( - providers: ReadonlyArray>, + providers: Providers, + settings: Pick, ): ModelSelection | null { const provider = providers.find( (candidate) => @@ -46,7 +136,50 @@ export function orchestratorModelSelection( candidate.installed && candidate.models.some((model) => model.slug === ORCHESTRATOR_MODEL), ); - return provider ? { instanceId: provider.instanceId, model: ORCHESTRATOR_MODEL } : null; + return provider + ? withReasoningEffort( + providers, + { instanceId: provider.instanceId, model: ORCHESTRATOR_MODEL }, + settings.orchestratorEffort, + ) + : null; +} + +/** The efforts a new orchestrator can start on: those of the orchestrator model. */ +export function orchestratorEffortChoices( + providers: Providers, +): ReadonlyArray { + const selection = orchestratorModelSelection(providers, { orchestratorEffort: null }); + return selection === null ? [] : reasoningEffortChoices(providers, selection); +} + +/** The efforts workers can start on: those of the model they start on, else of the orchestrator model. */ +export function orchestratorWorkerEffortChoices( + settings: ServerSettings, + providers: Providers, + orchestrator: { readonly projectId: ProjectId | null; readonly modelSelection?: ModelSelection }, +): ReadonlyArray { + const selection = + resolveProjectSettings(settings, orchestrator.projectId).settings.defaultModelSelection ?? + orchestrator.modelSelection; + const choices = selection ? reasoningEffortChoices(providers, selection) : []; + // An unsent draft can still name a model its provider no longer lists. + return choices.length > 0 ? choices : orchestratorEffortChoices(providers); +} + +/** The patch that sets the effort this orchestrator's workers start on; null follows the default. */ +export function orchestratorThreadWorkerEffortPatch( + current: ServerSettings["orchestratorThreadWorkerEfforts"], + threadId: ThreadId, + effort: string | null, +): ServerSettingsPatch { + const others = current.filter((entry) => entry.threadId !== threadId); + return { + orchestratorThreadWorkerEfforts: (effort === null + ? others + : [...others, { threadId, effort }] + ).slice(-MAX_ORCHESTRATOR_THREADS), + }; } /** The settings patch that turns orchestrator mode on or off for a thread, or null when nothing changes. */