Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions apps/server/src/mcp/toolkits/subagent/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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({
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/subagent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/mcp/toolkits/thread/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: () =>
Expand Down
48 changes: 46 additions & 2 deletions apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude unavailable provider snapshots from model inference

When a provider is enabled but currently unavailable, its snapshot can still advertise fallback models (for example checkClaudeProviderStatus returns models: allModels on CLI/auth probe failures). Because inference only filters on providerInstance.enabled, model: "claude-sonnet-4-6" will prefer the native claudeAgent entry over a working Cursor instance whenever Claude is enabled-but-broken, causing new threads/subagents to route to a provider that cannot run instead of to an actually available provider. Please filter the snapshot by provider status/auth availability, or otherwise rank unavailable snapshots after available ones; the same source-building pattern in the subagent handler needs the same treatment.

Useful? React with 👍 / 👎.

(providerInstance) =>
Effect.map(providerInstance.snapshot.getSnapshot, (snapshot) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the merged provider catalog for model lookup

This reads each raw instance snapshot, but the model picker is backed by ProviderRegistry, which hydrates cached provider status and merges prior models when a live/pending snapshot is empty. Right after restart, Codex's pending snapshot contains only custom models until the probe finishes, while the picker can still show cached built-in models such as gpt-5.4; an explicit model: "gpt-5.4" then fails as “not served” even though the user selected a visible model. Resolve against the same merged provider catalog (or equivalent cached merge) for both thread and subagent source-building.

Useful? React with 👍 / 👎.

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 =
Expand Down Expand Up @@ -269,7 +291,29 @@ export const ThreadStartRuntimeLive = Layer.effectDiscard(
const resolveModelSelection = (
input: ThreadStartToolInput,
sourceThread: OrchestrationThreadShell,
): ModelSelection => input.modelSelection ?? sourceThread.modelSelection;
modelSources: ReadonlyArray<ProviderModelSource>,
): Effect.Effect<ModelSelection, ThreadStartToolError> => {
// 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(
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions packages/contracts/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,20 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>>
[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<ProviderDriverKind> = [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move inference priority out of contracts

AGENTS.md says packages/contracts must stay schema-only, but this new export is runtime routing policy consumed by @t3tools/shared/model to decide which provider wins. Keeping provider inference priority in contracts makes contract consumers depend on server/model-picker policy and expands the package boundary; move this constant to a runtime/shared module instead of adding more non-schema logic here.

Useful? React with 👍 / 👎.

CODEX_DRIVER_KIND,
CLAUDE_DRIVER_KIND,
GROK_DRIVER_KIND,
OPENCODE_DRIVER_KIND,
CURSOR_DRIVER_KIND,
];
115 changes: 115 additions & 0 deletions packages/shared/src/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
getProviderOptionStringSelectionValue,
isClaudeUltrathinkPrompt,
normalizeModelSlug,
pickModelSelectionFromInstances,
type ProviderModelSource,
resolveModelSlugForProvider,
resolveSelectableModel,
trimOrNull,
Expand Down Expand Up @@ -231,3 +233,116 @@ describe("descriptor helpers", () => {
expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true);
});
});

describe("pickModelSelectionFromInstances", () => {
const source = (
id: string,
driver: string,
slugs: ReadonlyArray<string>,
): 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<ProviderModelSource> = [
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",
});
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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<ProviderModelSource> = [
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<ProviderModelSource> = [
{
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();
});
});
Loading
Loading