diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.ts b/apps/server/src/mcp/toolkits/subagent/handlers.ts index 09f2a77c0f3..52b74a2f70c 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.ts @@ -19,6 +19,10 @@ import { type ModelSelection, type OrchestrationThread, } from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + pickModelSelectionFromInstances, +} from "@t3tools/shared/model"; import { Cron } from "croner"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -188,13 +192,53 @@ const spawnSubagent = Effect.fn("SubagentToolkit.spawn")(function* (input: Spawn }), ), ); - const modelSelection: ModelSelection = threadStartInput.modelSelection ?? source.modelSelection; + const providerInstances = yield* runtime.providerInstanceRegistry.listInstances; + const modelSources = yield* Effect.forEach( + providerInstances.filter((providerInstance) => providerInstance.enabled), + (providerInstance) => + Effect.map(providerInstance.snapshot.getSnapshot, (snapshot) => ({ + instanceId: providerInstance.instanceId, + driverKind: providerInstance.driverKind, + models: snapshot.models.map((providerModel) => ({ + slug: providerModel.slug, + defaultOptions: buildProviderOptionSelectionsFromDescriptors( + providerModel.capabilities?.optionDescriptors, + ), + })), + })), + ); + // An explicit bare `model` resolves against the live provider model lists; a + // named model no provider serves fails loudly instead of silently spawning on + // a different (inherited) model. Prefer the source thread's instance on ties. + let modelSelection: ModelSelection; + if (threadStartInput.model !== undefined) { + const resolved = pickModelSelectionFromInstances( + threadStartInput.model, + modelSources, + source.modelSelection.instanceId, + ); + if (resolved === null) { + return yield* fail( + `Model "${threadStartInput.model}" is not served by any configured provider. Pass a model shown in the model picker, or omit "model" to keep the thread's current model.`, + ); + } + modelSelection = resolved; + } else { + modelSelection = threadStartInput.modelSelection ?? source.modelSelection; + } const instance = yield* runtime.providerInstanceRegistry.getInstance(modelSelection.instanceId); if (instance === undefined) { return yield* fail(`Provider instance ${modelSelection.instanceId} is not available.`); } - const started = yield* spawnRuntime(threadStartInput, invocation); + // Spawn with the ALREADY-resolved selection (drop `model`) so the thread + // runtime does not re-resolve against a possibly-different registry snapshot — + // the coordinator record and the started thread then share one selection. + const { model: _resolvedModel, ...threadStartInputWithSelection } = threadStartInput; + const started = yield* spawnRuntime( + { ...threadStartInputWithSelection, modelSelection }, + invocation, + ); const spawnedAtMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); yield* coordinator.register({ diff --git a/apps/server/src/mcp/toolkits/subagent/tools.ts b/apps/server/src/mcp/toolkits/subagent/tools.ts index 5c0c7d6edf7..1d102d0befb 100644 --- a/apps/server/src/mcp/toolkits/subagent/tools.ts +++ b/apps/server/src/mcp/toolkits/subagent/tools.ts @@ -191,7 +191,7 @@ export type ScheduleDeleteOutput = typeof ScheduleDeleteOutput.Type; export const SpawnSubagentTool = Tool.make("t3_spawn_subagent", { description: - "Delegate a unit of work to an autonomous sub-agent thread. Use this freely to fan out background or parallel work — research, refactors, exploring an approach — without blocking yourself. Defaults to detached=true: the sub-agent runs independently and wakes you with its result when it finishes, so prefer spawning detached and continuing your own work. Set detached=false only when you must have the result before proceeding; then optionally pass waitTimeoutSeconds for the foreground wait budget. Defaults to a new Git worktree off the repository default branch. This is the delegation primitive — for human-requested thread creation use t3_thread_start instead.", + "Delegate a unit of work to an autonomous sub-agent thread. Use this freely to fan out background or parallel work — research, refactors, exploring an approach — without blocking yourself. Defaults to detached=true: the sub-agent runs independently and wakes you with its result when it finishes, so prefer spawning detached and continuing your own work. Set detached=false only when you must have the result before proceeding; then optionally pass waitTimeoutSeconds for the foreground wait budget. Defaults to a new Git worktree off the repository default branch. To pick the sub-agent's model, pass `model` as a plain model name (e.g. 'claude-opus-4-8' or 'gpt-5.4'); the provider/harness is inferred automatically, so you never guess a harness/instance id. This is the delegation primitive — for human-requested thread creation use t3_thread_start instead.", parameters: SpawnSubagentInput, success: SpawnSubagentOutput, failure: ThreadStartToolError, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.test.ts b/apps/server/src/mcp/toolkits/thread/handlers.test.ts index c4d46cdc25e..15a4ff55a6a 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.test.ts @@ -20,6 +20,7 @@ import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import * as BootstrapTurnStartDispatcher from "../../../orchestration/Services/BootstrapTurnStartDispatcher.ts"; import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderInstanceRegistry } from "../../../provider/Services/ProviderInstanceRegistry.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { ThreadToolkitRegistrationLive } from "../../McpHttpServer.ts"; import { ThreadStartRuntimeLive } from "./handlers.ts"; @@ -117,6 +118,11 @@ const makeTestLayer = (commands: OrchestrationCommand[]) => { getThreadShellById: () => Effect.succeed(Option.some(sourceThread)), }), ), + Layer.provide( + Layer.mock(ProviderInstanceRegistry)({ + listInstances: Effect.succeed([]), + }), + ), Layer.provide( Layer.mock(GitWorkflowService)({ listRefs: () => diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 650569b7203..575d5585ce5 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -7,6 +7,11 @@ import { type OrchestrationThreadShell, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { + buildProviderOptionSelectionsFromDescriptors, + pickModelSelectionFromInstances, + type ProviderModelSource, +} from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -17,6 +22,7 @@ import * as Schema from "effect/Schema"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as BootstrapTurnStartDispatcher from "../../../orchestration/Services/BootstrapTurnStartDispatcher.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderInstanceRegistry } from "../../../provider/Services/ProviderInstanceRegistry.ts"; import { GitWorkflowService } from "../../../git/GitWorkflowService.ts"; import { ThreadStartToolError, @@ -63,6 +69,7 @@ export const activeThreadStartRuntimeOf = (): ActiveThreadStartRuntime | null => const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime")(function* () { const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const providerInstanceRegistry = yield* ProviderInstanceRegistry; const gitWorkflow = yield* GitWorkflowService; const uuid = () => crypto.randomUUIDv4.pipe(Effect.orDie); @@ -179,7 +186,22 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime" const worktreePath: string | null = mode === "existing_worktree" ? (input.worktreePath ?? null) : null; const title = input.title ?? truncateTitle(input.prompt); - const modelSelection = resolveModelSelection(input, sourceThread); + const providerInstances = yield* providerInstanceRegistry.listInstances; + const modelSources = yield* Effect.forEach( + providerInstances.filter((providerInstance) => providerInstance.enabled), + (providerInstance) => + Effect.map(providerInstance.snapshot.getSnapshot, (snapshot) => ({ + instanceId: providerInstance.instanceId, + driverKind: providerInstance.driverKind, + models: snapshot.models.map((providerModel) => ({ + slug: providerModel.slug, + defaultOptions: buildProviderOptionSelectionsFromDescriptors( + providerModel.capabilities?.optionDescriptors, + ), + })), + })), + ); + const modelSelection = yield* resolveModelSelection(input, sourceThread, modelSources); const runtimeMode = input.runtimeMode ?? sourceThread.runtimeMode; const interactionMode = input.interactionMode ?? sourceThread.interactionMode; const prepareWorktree = @@ -269,7 +291,29 @@ export const ThreadStartRuntimeLive = Layer.effectDiscard( const resolveModelSelection = ( input: ThreadStartToolInput, sourceThread: OrchestrationThreadShell, -): ModelSelection => input.modelSelection ?? sourceThread.modelSelection; + modelSources: ReadonlyArray, +): Effect.Effect => { + // An explicit bare `model` is resolved against the live provider model lists; + // if the caller named a model no provider serves, fail loudly rather than + // silently starting the thread on a different (inherited) model. + if (input.model !== undefined) { + const resolved = pickModelSelectionFromInstances( + input.model, + modelSources, + sourceThread.modelSelection.instanceId, + ); + if (resolved === null) { + return Effect.fail( + fail( + `Model "${input.model}" is not served by any configured provider. Pass a model shown in the model picker, or omit "model" to keep the thread's current model.`, + ), + ); + } + return Effect.succeed(resolved); + } + // Otherwise an explicit modelSelection wins, else inherit the source thread. + return Effect.succeed(input.modelSelection ?? sourceThread.modelSelection); +}; const startThread = Effect.fn("ThreadToolkit.startThread")(function* (input: ThreadStartToolInput) { const invocation = yield* McpInvocationContext.requireMcpCapability("thread-management").pipe( diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 2d70d9f0707..524d8a167b2 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -30,6 +30,7 @@ export const ThreadStartToolInput = Schema.Struct({ baseBranch: Schema.optional(TrimmedNonEmptyString), baseBranchSource: Schema.optional(ThreadStartBaseBranchSource), runSetupScript: Schema.optional(Schema.Boolean), + model: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), @@ -57,7 +58,7 @@ const dependencies = [McpInvocationContext.McpInvocationContext]; export const ThreadStartTool = Tool.make("t3_thread_start", { description: - "Start a new T3 Code thread with the supplied initial prompt, only when the user explicitly asks to start/spawn/create another thread or agent. Do not use for autonomous delegation or background parallel work. Defaults to creating a new Git worktree from the repository default branch. Use current_checkout only when the user explicitly asks for the same checkout. This tool launches the child turn and returns metadata without waiting for completion.", + "Start a new T3 Code thread with the supplied initial prompt, only when the user explicitly asks to start/spawn/create another thread or agent. Do not use for autonomous delegation or background parallel work. Defaults to creating a new Git worktree from the repository default branch. Use current_checkout only when the user explicitly asks for the same checkout. To choose the model, pass `model` as a plain model name (e.g. 'claude-opus-4-8' or 'gpt-5.4') — the provider/harness is inferred automatically, so you never need to know or pass a harness/instance id. This tool launches the child turn and returns metadata without waiting for completion.", parameters: ThreadStartToolInput, success: ThreadStartToolOutput, failure: ThreadStartToolError, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 7788eaace48..545a3b1fd97 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -206,3 +206,20 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", }; + +/** + * Preference order for inferring a model's official provider/harness from its + * name (see `inferProviderFromModel`). Native providers come first; aggregator + * providers that re-list other providers' models (e.g. Cursor exposes Claude + * models) come last, so a model offered by several providers resolves to its + * official one — Claude models to `claudeAgent`, not `cursor`. This is an + * attribute of provider identity, not of model names, so it needs no change + * when new models ship: those are picked up from the registry maps above. + */ +export const PROVIDER_INFERENCE_PRIORITY: ReadonlyArray = [ + CODEX_DRIVER_KIND, + CLAUDE_DRIVER_KIND, + GROK_DRIVER_KIND, + OPENCODE_DRIVER_KIND, + CURSOR_DRIVER_KIND, +]; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 00c01616005..6c7afbc3da0 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -18,6 +18,8 @@ import { getProviderOptionStringSelectionValue, isClaudeUltrathinkPrompt, normalizeModelSlug, + pickModelSelectionFromInstances, + type ProviderModelSource, resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, @@ -231,3 +233,116 @@ describe("descriptor helpers", () => { expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true); }); }); + +describe("pickModelSelectionFromInstances", () => { + const source = ( + id: string, + driver: string, + slugs: ReadonlyArray, + ): ProviderModelSource => ({ + instanceId: ProviderInstanceId.make(id), + driverKind: ProviderDriverKind.make(driver), + models: slugs.map((slug) => ({ slug, defaultOptions: undefined })), + }); + + // Mirrors what the runtime registry reports (each provider's live models). + const sources: ReadonlyArray = [ + source("codex", "codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.4-mini"]), + source("claudeAgent", "claudeAgent", [ + "claude-opus-4-8", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5", + // Present in the live provider list but NOT in the registry alias maps: + "claude-fable-5", + ]), + // The Cursor aggregator also re-lists some Claude models. + source("cursor", "cursor", ["composer-2", "claude-sonnet-4-6", "claude-opus-4-6"]), + source("grok", "grok", ["grok-build"]), + ]; + + it("matches plain canonical models against the live provider lists", () => { + expect(pickModelSelectionFromInstances("claude-opus-4-8", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-8", + }); + expect(pickModelSelectionFromInstances("gpt-5.4", sources)).toEqual({ + instanceId: "codex", + model: "gpt-5.4", + }); + expect(pickModelSelectionFromInstances("grok-build", sources)).toEqual({ + instanceId: "grok", + model: "grok-build", + }); + }); + + it("routes a newly-added live model with no code change (e.g. Fable 5)", () => { + // The critical case: claude-fable-5 is served by the provider but is not in + // any alias/default map. Because we match the LIVE list, it still resolves. + expect(pickModelSelectionFromInstances("claude-fable-5", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-fable-5", + }); + }); + + it("resolves registry aliases to the canonical live slug", () => { + expect(pickModelSelectionFromInstances("opus", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-8", + }); + expect(pickModelSelectionFromInstances("gpt-5-codex", sources)).toEqual({ + instanceId: "codex", + model: "gpt-5.4", + }); + }); + + it("prefers the native provider when several serve the same model", () => { + // claude-sonnet-4-6 is served by both claudeAgent and the cursor aggregator. + expect(pickModelSelectionFromInstances("claude-sonnet-4-6", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-sonnet-4-6", + }); + }); + + it("routes a provider-specific alias's canonical model to the native provider", () => { + // "opus-4.6-thinking" is a Cursor-only alias -> claude-opus-4-6, which both + // claudeAgent and cursor serve. The native provider must still win. + expect(pickModelSelectionFromInstances("opus-4.6-thinking", sources)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-6", + }); + }); + + it("prefers the source instance when one provider has several instances", () => { + const multi: ReadonlyArray = [ + source("codex_personal", "codex", ["gpt-5.4"]), + source("codex_work", "codex", ["gpt-5.4"]), + ]; + expect( + pickModelSelectionFromInstances("gpt-5.4", multi, ProviderInstanceId.make("codex_work")), + ).toEqual({ instanceId: "codex_work", model: "gpt-5.4" }); + }); + + it("preserves the matched model's default options", () => { + const withOptions: ReadonlyArray = [ + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driverKind: ProviderDriverKind.make("claudeAgent"), + models: [{ slug: "claude-opus-4-8", defaultOptions: [{ id: "effort", value: "high" }] }], + }, + ]; + expect(pickModelSelectionFromInstances("claude-opus-4-8", withOptions)).toEqual({ + instanceId: "claudeAgent", + model: "claude-opus-4-8", + options: [{ id: "effort", value: "high" }], + }); + }); + + it("returns null for unknown or empty models so callers can fall back", () => { + expect(pickModelSelectionFromInstances("totally-unknown-model", sources)).toBeNull(); + expect(pickModelSelectionFromInstances("", sources)).toBeNull(); + expect(pickModelSelectionFromInstances(" ", sources)).toBeNull(); + expect(pickModelSelectionFromInstances(null, sources)).toBeNull(); + expect(pickModelSelectionFromInstances("gpt-5.4", [])).toBeNull(); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 9fdbd6c0447..f0978de6554 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -4,6 +4,7 @@ import { MODEL_SLUG_ALIASES_BY_PROVIDER, type ModelCapabilities, type ModelSelection, + PROVIDER_INFERENCE_PRIORITY, ProviderDriverKind, ProviderInstanceId, type ProviderOptionDescriptor, @@ -300,6 +301,113 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } +/** A single model a provider instance serves, plus its default option selections. */ +export interface ProviderModelEntry { + readonly slug: string; + readonly defaultOptions: ReadonlyArray | undefined; +} + +/** + * A live provider instance's model catalog, as seen by the runtime provider + * registry: the instance's routing id, its driver kind, and the models it + * currently serves (each with its default options). This is the same + * upstream-maintained source the model picker uses, so it already includes + * newly-added built-in models (e.g. `claude-fable-5`) and any custom models. + * Callers should include ENABLED instances only. + */ +export interface ProviderModelSource { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; + readonly models: ReadonlyArray; +} + +function providerPriorityIndex(driver: ProviderDriverKind): number { + const index = PROVIDER_INFERENCE_PRIORITY.indexOf(driver); + // Providers not yet ranked sort last, so a newly-introduced provider still + // resolves (just at lowest priority) without a code change here. + return index === -1 ? PROVIDER_INFERENCE_PRIORITY.length : index; +} + +function makeModelSelection( + instanceId: ProviderInstanceId, + slug: string, + defaultOptions: ReadonlyArray | undefined, +): ModelSelection { + // Preserve the model's default option selections (e.g. reasoning effort) so a + // plain-model choice matches what the picker would apply; omit when there are none. + return defaultOptions && defaultOptions.length > 0 + ? { instanceId, model: slug, options: defaultOptions } + : { instanceId, model: slug }; +} + +/** + * Resolve a plain model name to a `ModelSelection` against the LIVE provider + * model lists, so a caller never has to know or guess a harness/instance id — + * they pass e.g. `claude-opus-4-8`, `gpt-5.4`, or a future `fable-5` and the + * official provider is found from the registry data itself (NO hardcoded + * model-name patterns). Selection order: the native provider first + * (`PROVIDER_INFERENCE_PRIORITY`, so Claude models resolve to `claudeAgent`, not + * the Cursor aggregator), and within a provider the `preferInstanceId` (usually + * the source thread's instance) wins so a multi-instance setup keeps continuity. + * Alias inputs (e.g. `opus`) resolve through the registry alias maps and then + * match the live lists; the matched model's default options are preserved. + * Returns null when unresolved so callers can fall back to an inherited selection. + */ +export function pickModelSelectionFromInstances( + model: string | null | undefined, + sources: ReadonlyArray, + preferInstanceId?: ProviderInstanceId, +): ModelSelection | null { + const trimmed = typeof model === "string" ? model.trim() : ""; + if (!trimmed) return null; + + // Lower rank wins: native provider first, then the preferred (source) instance. + const rank = (source: ProviderModelSource): number => + providerPriorityIndex(source.driverKind) * 2 + + (preferInstanceId !== undefined && source.instanceId === preferInstanceId ? 0 : 1); + + // Best (highest-priority, source-preferred) instance that serves an exact slug. + const resolveSlug = (slug: string): ModelSelection | null => { + const best = sources + .filter((source) => source.models.some((entry) => entry.slug === slug)) + .sort((a, b) => rank(a) - rank(b))[0]; + if (best === undefined) return null; + const entry = best.models.find((candidate) => candidate.slug === slug); + return makeModelSelection(best.instanceId, slug, entry?.defaultOptions); + }; + + // 1. Direct match against the models each provider actually serves. + const direct = resolveSlug(trimmed); + if (direct !== null) return direct; + + // 2. Resolve the input through the registry alias maps to its canonical slug(s), + // then match each canonical against ALL providers with the same native-priority + // preference — so a provider-specific alias (e.g. the Cursor-only + // "opus-4.6-thinking") still routes its canonical model to the official + // provider rather than the aliasing one. + const canonicals = new Set(); + for (const source of sources) { + const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[source.driverKind] ?? {}; + const canonical = Object.prototype.hasOwnProperty.call(aliases, trimmed) + ? aliases[trimmed] + : undefined; + if (typeof canonical === "string") canonicals.add(canonical); + } + let best: ModelSelection | null = null; + let bestRank = Number.POSITIVE_INFINITY; + for (const canonical of canonicals) { + const selection = resolveSlug(canonical); + if (selection === null) continue; + const chosen = sources.find((source) => source.instanceId === selection.instanceId); + const selectionRank = chosen ? rank(chosen) : Number.POSITIVE_INFINITY; + if (selectionRank < bestRank) { + best = selection; + bestRank = selectionRank; + } + } + return best; +} + /** Trim a string, returning null for empty/missing values. */ export function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null;