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
171 changes: 171 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EnvironmentId,
MessageId,
NodeId,
type OrchestratorMcpCapabilitiesResult,
OrchestratorMcpTarget,
type OrchestrationV2ThreadShell,
type ScheduledTask,
Expand Down Expand Up @@ -1156,6 +1157,176 @@ describe("OrchestratorMcpService provider resolution", () => {
}),
);

describe("listed models", () => {
const opencodeInstanceId = ProviderInstanceId.make("opencode");
const model = (slug: string, extra: Partial<ServerProvider["models"][number]> = {}) => ({
slug,
name: slug,
isCustom: false,
capabilities: null,
...extra,
});
const opencodeSlugs = Array.from(
{ length: 12 },
(_, index) => `oc/m${String(index + 1).padStart(2, "0")}`,
);
const catalog: ReadonlyArray<ServerProvider> = [
{
...providerSnapshot({
instanceId: codexInstanceId,
driver: ProviderDriverKind.make("codex"),
}),
models: [
model("gpt-6", {
name: "Sol",
capabilities: {
optionDescriptors: [
{
id: "effort",
label: "Reasoning",
description: "How hard the model thinks.",
type: "select",
options: [
{ id: "low", label: "Low", description: "Fast." },
{ id: "high", label: "High", isDefault: true },
],
promptInjectedValues: ["high"],
},
{ id: "fastMode", label: "Fast", type: "boolean" },
{
id: "agent",
label: "Agent",
type: "select",
options: [{ id: "build", label: "Build" }],
currentValue: "build",
},
],
},
}),
model("gpt-5", { isLegacy: true }),
model("gpt-4", { isLegacy: true }),
model("gpt-3", { name: "GPT-3", isLegacy: true }),
model("my-alpha", { isCustom: true }),
],
},
{
...providerSnapshot({
instanceId: opencodeInstanceId,
driver: ProviderDriverKind.make("opencode"),
}),
models: [
...opencodeSlugs.map((slug) => model(slug, { isLegacy: slug === "oc/m03" })),
model("oc-custom", { isCustom: true }),
],
},
];

const capabilitiesWith = (search?: string) =>
Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
return yield* service.capabilities(scope, search === undefined ? {} : { search });
}).pipe(
Effect.provide(
layerOrchestratorService.pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.mock(ThreadManagementService.ThreadManagementService)({
getThreadRecords: () =>
Effect.succeed(
parentProjection([], { instanceId: codexInstanceId, model: "gpt-3" }),
),
}),
ServerSettings.layerTest({
favorites: [
{ provider: codexInstanceId, model: "gpt-4" },
{ provider: opencodeInstanceId, model: "oc/m10" },
],
providerModelPreferences: {
[opencodeInstanceId]: { hiddenModels: ["oc/m02"], modelOrder: ["oc/m12"] },
},
}),
providerRegistryLayer(catalog),
adapterRegistryLayer([codexInstanceId, opencodeInstanceId]),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
),
),
),
),
);

const listing = (capabilities: OrchestratorMcpCapabilitiesResult) =>
capabilities.providers.map((provider) => ({
id: provider.providerInstanceId,
models: provider.models.map((entry) => entry.id),
omitted: provider.omittedModels ?? 0,
}));

it.effect(
"skips legacy models, lists the first 8 of large catalogs, and keeps pinned models",
() =>
Effect.gen(function* () {
assert.deepEqual(listing(yield* capabilitiesWith()), [
// gpt-4 is a favorite and gpt-3 is the caller's model; legacy gpt-5 is left out.
{ id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 },
{
id: opencodeInstanceId,
models: [
"oc/m12",
"oc/m01",
"oc/m04",
"oc/m05",
"oc/m06",
"oc/m07",
"oc/m08",
"oc/m09",
"oc/m10",
"oc-custom",
],
omitted: 2,
},
]);
}),
);

it.effect("search matches every word and caps each provider's matches", () =>
Effect.gen(function* () {
assert.deepEqual(listing(yield* capabilitiesWith("GPT 5")), [
{ id: codexInstanceId, models: ["gpt-5"], omitted: 0 },
{ id: opencodeInstanceId, models: [], omitted: 0 },
]);
const opencode = listing(yield* capabilitiesWith("oc/m"))[1];
assert.deepEqual(opencode, {
id: opencodeInstanceId,
models: ["oc/m12", "oc/m01", "oc/m03", "oc/m04", "oc/m05", "oc/m06", "oc/m07", "oc/m08"],
omitted: 3,
});
}),
);

it.effect("describes models by id, distinct label, and option values", () =>
Effect.gen(function* () {
const codex = (yield* capabilitiesWith()).providers[0];
assert.deepEqual(codex?.models.slice(0, 4), [
{
id: "gpt-6",
label: "Sol",
options: [
{ id: "effort", values: ["low", "high"], default: "high" },
{ id: "fastMode", values: [false, true] },
{ id: "agent", values: ["build"], default: "build" },
],
},
{ id: "gpt-4" },
{ id: "gpt-3" },
{ id: "my-alpha" },
]);
}),
);
});

it.effect("rejects delegation to a provider without a registered adapter", () =>
Effect.gen(function* () {
const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly");
Expand Down
112 changes: 91 additions & 21 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
type OrchestrationV2ThreadLaunchWorkspaceStrategy,
type OrchestrationV2TurnItem,
OrchestratorMcpFailure,
type OrchestratorMcpCapabilitiesInput,
type OrchestratorMcpCapabilitiesResult,
type OrchestratorMcpModelOption,
type OrchestratorMcpCreateThreadsInput,
type OrchestratorMcpCreateThreadsResult,
type OrchestratorMcpCreatedThread,
Expand Down Expand Up @@ -59,9 +61,11 @@ import {
type ScheduledTask,
type ScheduledTaskUpsertInput,
type ServerProvider,
type ServerProviderModel,
ThreadId,
isOrchestrationV2WorkActive,
} from "@t3tools/contracts";
import { getProviderOptionCurrentValue } from "@t3tools/shared/model";
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
import {
normalizeThreadPullRequestKey,
Expand Down Expand Up @@ -136,6 +140,7 @@ type TerminalTaskStatus = Extract<
export interface OrchestratorMcpServiceShape {
readonly capabilities: (
scope: McpInvocationScope,
input?: OrchestratorMcpCapabilitiesInput,
) => Effect.Effect<OrchestratorMcpCapabilitiesResult, OrchestratorMcpFailure>;
readonly delegateTask: (
scope: McpInvocationScope,
Expand Down Expand Up @@ -303,6 +308,61 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato
};
}

/**
* Large catalogs (OpenCode, Cursor, Pi) can list hundreds of models, so agents
* get a short list by default and search for the rest. These providers have
* small official catalogs, so all their current models are listed.
*/
const FULL_CATALOG_DRIVERS: ReadonlySet<string> = new Set(["codex", "claudeAgent", "muse", "grok"]);
const LISTED_MODEL_LIMIT = 8;

/**
* Picks the models orchestrator_capabilities shows for one provider. Hidden
* models never appear. With search terms, lists matches; otherwise lists pinned
* models (custom, favorite, the caller's own) plus the provider's non-legacy
* models, or only the first few in the user's model order for large catalogs.
*/
function listCapabilityModels(input: {
readonly provider: ServerProvider;
readonly preferences:
| { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string> }
| undefined;
readonly isPinned: (model: ServerProviderModel) => boolean;
readonly searchTerms: ReadonlyArray<string>;
}) {
const rank = new Map(input.preferences?.modelOrder.map((slug, index) => [slug, index]));
const visible = input.provider.models
.filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug))
.toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size));
if (input.searchTerms.length > 0) {
const matches = visible.filter((model) => {
const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase();
return input.searchTerms.every((term) => text.includes(term));
});
const listed = matches.slice(0, LISTED_MODEL_LIMIT);
return { listed, omitted: matches.length - listed.length };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const current = visible.filter((model) => !model.isLegacy);
const shown = new Set(
FULL_CATALOG_DRIVERS.has(input.provider.driver)
? current
: current.slice(0, LISTED_MODEL_LIMIT),
);
const listed = visible.filter((model) => input.isPinned(model) || shown.has(model));
return { listed, omitted: visible.length - listed.length };
}

/** Option ids and allowed values are all a delegating agent needs; labels are UI copy. */
function capabilityModelOption(descriptor: ProviderOptionDescriptor): OrchestratorMcpModelOption {
const defaultValue = getProviderOptionCurrentValue(descriptor);
return {
id: descriptor.id,
values:
descriptor.type === "boolean" ? [false, true] : descriptor.options.map((choice) => choice.id),
...(defaultValue === undefined ? {} : { default: defaultValue }),
};
}

function providerConstraints(
provider: ServerProvider | undefined,
supportsOrchestrationV2: boolean,
Expand Down Expand Up @@ -2011,44 +2071,54 @@ const make = Effect.gen(function* () {
return { status, secretRef: secretRef.value };
}),

capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope) {
capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope, input = {}) {
const { parent, limits } = yield* loadCaller(scope);
const providers = yield* loadProviders;
const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();
const modelPreferences = yield* serverSettings.getSettings.pipe(
Effect.map((settings) => settings.providerModelPreferences ?? {}),
const settings = yield* serverSettings.getSettings.pipe(
Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")),
);
const inherited = parent?.thread.modelSelection;
const searchTerms = input.search?.toLowerCase().split(/\s+/) ?? [];
return {
parentThreadId: parent?.thread.id ?? null,
inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,
inheritedModel: parent?.thread.modelSelection.model ?? null,
inheritedOptions: parent?.thread.modelSelection.options ?? [],
inheritedProviderInstanceId: inherited?.instanceId ?? null,
inheritedModel: inherited?.model ?? null,
inheritedOptions: inherited?.options ?? [],
runtimeMode: limits.runtimeMode,
interactionMode: limits.interactionMode,
providers: providers.map((provider) => {
const constraints = providerConstraints(
provider,
orchestrationCapableInstanceIds.has(provider.instanceId),
);
const { listed, omitted } = listCapabilityModels({
provider,
preferences: settings.providerModelPreferences?.[provider.instanceId],
searchTerms,
isPinned: (model) =>
model.isCustom ||
(inherited?.instanceId === provider.instanceId && inherited.model === model.slug) ||
(settings.favorites ?? []).some(
(favorite) =>
favorite.provider === provider.instanceId && favorite.model === model.slug,
),
});
return {
providerInstanceId: provider.instanceId,
driverKind: provider.driver,
displayName: provider?.displayName ?? null,
models:
provider?.models
.filter(
(model) =>
model.isCustom ||
!modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug),
)
.map((model) => ({
id: model.slug,
label: model.name ?? null,
...(model.capabilities?.optionDescriptors === undefined
? {}
: { options: model.capabilities.optionDescriptors }),
})) ?? [],
displayName: provider.displayName ?? null,
models: listed.map((model) => {
const options = model.capabilities?.optionDescriptors ?? [];
return {
id: model.slug,
...(model.name.toLowerCase() === model.slug.toLowerCase()
? {}
: { label: model.name }),
...(options.length === 0 ? {} : { options: options.map(capabilityModelOption) }),
};
}),
...(omitted === 0 ? {} : { omittedModels: omitted }),
...(provider.supportedRuntimeModes === undefined ||
provider.supportedRuntimeModes.length === 0
? {}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/orchestrator/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";

const handlers = {
orchestrator_capabilities: McpToolAccess.reads(() =>
orchestrator_capabilities: McpToolAccess.reads((input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
return yield* service.capabilities(scope);
return yield* service.capabilities(scope, input);
}),
),
delegate_task: McpToolAccess.actsAsCaller((input) =>
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
OrchestratorMcpCapabilitiesInput,
OrchestratorMcpCapabilitiesResult,
OrchestrationV2StopWorkflowInput,
OrchestratorMcpCreateThreadsInput,
Expand Down Expand Up @@ -52,7 +53,8 @@ const threadMetadataDependencies = [

const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
description:
"List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.",
"List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.",
parameters: OrchestratorMcpCapabilitiesInput,
success: OrchestratorMcpCapabilitiesResult,
failure: OrchestratorMcpFailure,
failureMode: "return",
Expand Down
Loading
Loading