diff --git a/src/agent/agent-search.test.ts b/src/agent/agent-search.test.ts index be12c90b1..b2dfba129 100644 --- a/src/agent/agent-search.test.ts +++ b/src/agent/agent-search.test.ts @@ -12,17 +12,14 @@ const fixtures: AgentProfile[] = [ { id: "greybeard", description: "Seasoned architect — reviews for design and backwards compatibility", - tier: "clever", }, { id: "critique", description: "Code quality reviewer — tests assumptions and security smells", - tier: "standard", }, { id: "scout", description: "Fast codebase explorer — maps structure and entry points", - tier: "fast", }, ]; @@ -71,13 +68,11 @@ describe("formatAgentSearchResults", () => { id: "draper", description: "PR design reviewer from marketplace", source: "claude", - tier: "standard", systemPromptRole: body, }, ]); expect(text).toContain("### draper"); expect(text).toContain("[source: claude]"); - expect(text).toContain("[tier: standard]"); expect(text).toContain("System prompt / body:"); expect(text).toContain(body); expect(text).toContain("do not need read_file on plugin roots"); diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 021e142aa..9a6628006 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -68,13 +68,12 @@ function truncateAgentBody(body: string): string { // MAX_AGENT_SEARCH_BODY_CHARS are truncated with an ellipsis marker. function formatAgentProfileEntry(p: AgentProfile): string { const desc = (p.description ?? "").trim(); - const tier = p.tier !== undefined ? ` [tier: ${p.tier}]` : ""; const orch = p.orchestrator === true ? " [orchestrator]" : ""; const source = p.source !== undefined ? ` [source: ${p.source}]` : ""; const header = desc.length > 0 - ? `### ${p.id}${tier}${orch}${source}\n${desc}` - : `### ${p.id}${tier}${orch}${source}`; + ? `### ${p.id}${orch}${source}\n${desc}` + : `### ${p.id}${orch}${source}`; const body = (p.systemPromptRole ?? "").trim(); if (body.length === 0) return header; return `${header}\n\nSystem prompt / body:\n${truncateAgentBody(body)}`; diff --git a/src/agent/default-agents.ts b/src/agent/default-agents.ts index c2abbfcdf..af496019b 100644 --- a/src/agent/default-agents.ts +++ b/src/agent/default-agents.ts @@ -8,7 +8,6 @@ export const defaultAgentsPlugin: AgentPlugin = { { id: "greybeard", description: "Seasoned architect — reviews for design, constraint ownership, and backwards compatibility", - tier: "clever", systemPromptRole: "You are a seasoned software architect with decades of experience. " + "You review code and designs for architectural soundness, constraint ownership " + @@ -19,7 +18,6 @@ export const defaultAgentsPlugin: AgentPlugin = { { id: "critique", description: "Code quality reviewer — tests assumptions, finds edge cases and security smells", - tier: "standard", systemPromptRole: "You are a critical code reviewer focused on code quality, test coverage, " + "edge cases, and security-adjacent issues. You challenge assumptions, look for " + diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index e4825e6b1..d0607fa96 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -28,9 +28,8 @@ export type CapabilityFilter = { tools: string[]; }; -// A single provider/model/effort combo an agent can run on. Mirrors a tier leg -// but carries an optional reasoningEffort so an agent can pin "Sonnet + medium" -// or "Grok + high" without going through the tier abstraction. +// A single provider/model/effort combo an agent can run on, so an agent can +// pin "Sonnet + medium" or "Grok + high". export type InferenceLeg = { provider: string; model: string; @@ -50,12 +49,9 @@ export type AgentProfile = { // Unique identifier, used in workflow steps as `agent: "greybeard"`. id: string; description?: string; - // Provider tier alias for this agent. Resolved via settings.tiers to a - // concrete provider and model assignment. Used when `inference` is absent. - tier?: "fast" | "standard" | "clever"; - // Explicit per-agent model selection. Takes precedence over `tier` when set, - // so an agent can declare "Sonnet + medium reasoning" without going through - // the user's tier config. See InferenceSpec for resolution rules. + // Explicit per-agent model selection. When absent, the agent runs on the + // parent session's active provider/model. See InferenceSpec for + // resolution rules. inference?: InferenceSpec; // Optional tool restriction. Controls which tools the sub-agent can call. capabilities?: CapabilityFilter; diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index 8237dc888..5b940372f 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -40,7 +40,6 @@ const InferenceSpecSchema = type({ const AgentProfileSchema = type({ id: "string", "description?": "string", - "tier?": "'fast' | 'standard' | 'clever'", "inference?": InferenceSpecSchema, "capabilities?": CapabilityFilterSchema, "systemPromptRole?": "string", diff --git a/src/config.test.ts b/src/config.test.ts index ca4d02fd1..607e2c908 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -538,14 +538,11 @@ describe("buildProviderCatalog", () => { }); }); - test("runtimeSettingsWithCatalog overlays OAuth catalog entries for tier resolution", () => { + test("runtimeSettingsWithCatalog overlays OAuth catalog entries for provider resolution", () => { const disk = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "sk", models: ["gpt-4o"] }, }, - tiers: { - clever: { provider: "xai/work", model: "grok-4" }, - }, }; const catalog = [ { @@ -568,7 +565,6 @@ describe("buildProviderCatalog", () => { apiKey: "xai-token", models: ["grok-4"], }); - expect(runtime.tiers).toEqual(disk.tiers); // Disk persist path still strips OAuth. expect(providerCatalogToSettings(catalog, "openai", disk).providers["xai/work"]).toBeUndefined(); }); @@ -633,7 +629,6 @@ describe("buildProviderCatalog", () => { agentModelFallback: "none", shell: { timeoutMs: 30_000, maxTimeoutMs: 120_000 }, tools: { timeoutMs: 60_000 }, - tiers: { fast: { provider: "fp", model: "fp-large" } }, workflowProfiles: { fast: { implement: "fp-large" } }, }; const settings = providerCatalogToSettings( diff --git a/src/config/index.ts b/src/config/index.ts index a537e82f2..f5a548a5b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -47,10 +47,6 @@ import { normalizeOpenAICompatibleBaseURL, resolveProvider, type MCPServerConfig, - type ProviderTier, - type TierAssignment, - type TierConfig, - type TierDefinition, type ResolvedProvider, type Settings, type ProviderSettings, @@ -306,9 +302,8 @@ export type Config = { workflow?: string; // Deprecated no-op retained for CLI compatibility. noWorkflow: boolean; - tiers?: Partial>; /** - * Runtime settings view for tier/provider resolution. Includes OAuth provider + * Runtime settings view for provider resolution. Includes OAuth provider * projections from the live catalog that are never written to settings.json. * Do not pass this object to saveGlobalSettings — rebuild with * providerCatalogToSettings (or re-read disk) before any persist. @@ -580,8 +575,7 @@ export async function loadConfig( : settings?.mcpServers !== undefined ? { mcpServers: settings.mcpServers, mcpServersSource: "global" as const } : { mcpServersSource: "none" as const }), - ...(settings?.tiers !== undefined ? { tiers: settings.tiers } : {}), - // Runtime view includes OAuth projections so tier resolution can see + // Runtime view includes OAuth projections so inference resolution can see // Codex/xAI providers that are never written to settings.json. Not safe // to persist as-is — use providerCatalogToSettings or re-read disk. ...(settingsForResolution !== null ? { settings: settingsForResolution } : {}), @@ -613,7 +607,7 @@ export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): Pro } // Overlay the full live catalog (including OAuth profiles) onto settings for -// runtime tier/provider resolution. OAuth credentials live in home auth stores +// runtime provider resolution. OAuth credentials live in home auth stores // and are stripped from settings.json; the catalog is the source of truth for // which OAuth providers are available right now. Never pass the result to a // disk write path — use providerCatalogToSettings for persistence. diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 1ee165879..374a5d04f 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -9,15 +9,7 @@ import { buildXaiSource, type ProviderCatalogEntry, } from "./index.js"; -import type { - ProviderTier, - Settings, - TierAssignment, - TierDefinition, - TierProviderRef, - TierSelectionMode, -} from "./settings.js"; -import { PROVIDER_TIERS, resolveTierDefinition, tierDefinitionAt } from "./settings.js"; +import type { Settings } from "./settings.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import { SOURCE_MAX_TOKENS } from "./index.js"; import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; @@ -28,32 +20,24 @@ export type BuildSourceContext = { catalog: readonly ProviderCatalogEntry[]; }; -function refKey(ref: TierProviderRef): string { - return `${ref.provider}\0${ref.model}`; -} +// A resolved provider+model, with optional reasoningEffort — the unit both +// the primary source and its backups are built from. +export type ProviderRef = { provider: string; model: string; reasoningEffort?: ReasoningEffort }; -export function normalizeTierDefinition( - raw: TierAssignment | TierDefinition | undefined, -): TierDefinition | undefined { - if (raw === undefined) return undefined; - if ("order" in raw && Array.isArray(raw.order)) { - const def = raw as TierDefinition; - return { - mode: def.mode ?? "prefer", - order: def.order.filter((r) => r.provider.length > 0 && r.model.length > 0), - }; - } - const leg = raw as TierAssignment; - if (leg.provider.length === 0 || leg.model.length === 0) return undefined; - return { mode: "pin", order: [{ provider: leg.provider, model: leg.model }] }; +function refKey(ref: ProviderRef): string { + return `${ref.provider}\0${ref.model}`; } -function preferTailFromSettings( +// Every other configured provider, one model each, so a primary source that +// fails to build (bad credentials, missing baseURL) still has somewhere to +// fall back to. Order follows settings.providers; providers already covered +// by `existing` are skipped. +function backupRefsFromSettings( settings: Settings, - existing: readonly TierProviderRef[], -): TierProviderRef[] { + existing: readonly ProviderRef[], +): ProviderRef[] { const seenProviders = new Set(existing.map((r) => r.provider)); - const tail: TierProviderRef[] = []; + const tail: ProviderRef[] = []; for (const [provider, p] of Object.entries(settings.providers)) { if (seenProviders.has(provider)) continue; const model = p.defaultModel ?? p.models[0]; @@ -64,22 +48,6 @@ function preferTailFromSettings( return tail; } -export function tierProviderRefs( - tier: ProviderTier, - settings: Settings | undefined, - options?: { fallbackChain?: boolean }, -): TierProviderRef[] { - if (settings === undefined) return []; - const def = - options?.fallbackChain === true - ? resolveTierDefinition(tier, settings) - : tierDefinitionAt(tier, settings); - if (def === null) return []; - const head = def.order; - if (def.mode === "pin") return head; - return [...head, ...preferTailFromSettings(settings, head)]; -} - function catalogEntry( catalog: readonly ProviderCatalogEntry[], provider: string, @@ -98,7 +66,7 @@ function maxTokensFor( } export function buildInferenceSourceForRef( - ref: TierProviderRef, + ref: ProviderRef, ctx: BuildSourceContext, settings: Settings | undefined, ): InferenceSource | null { @@ -195,7 +163,7 @@ export function buildInferenceSourceForRef( } export function buildSourcesFromRefs( - refs: readonly TierProviderRef[], + refs: readonly ProviderRef[], ctx: BuildSourceContext, settings: Settings | undefined, ): InferenceSource[] { @@ -212,21 +180,22 @@ export function buildSourcesFromRefs( } export function prependActiveRef( - refs: readonly TierProviderRef[], - active: TierProviderRef, -): TierProviderRef[] { + refs: readonly ProviderRef[], + active: ProviderRef, +): ProviderRef[] { const without = refs.filter((r) => refKey(r) !== refKey(active)); return [active, ...without]; } -function buildTieredSourceBundle(args: { +// Builds the primary source for `head` plus one backup per other configured +// provider, so a mid-run failure (bad credentials, dropped connection) has +// somewhere else to go. `head` always wins as defaultSource when it builds. +function buildSourceBundle(args: { settings: Settings | undefined; catalog: readonly ProviderCatalogEntry[]; - tier: ProviderTier; - head: TierProviderRef; + head: ProviderRef; reasoningEffort?: ReasoningEffort; sessionId: string; - fallbackChain: boolean; }): { sources: InferenceSource[]; defaultSource: string } { const ctx: BuildSourceContext = { sessionId: args.sessionId, @@ -234,8 +203,9 @@ function buildTieredSourceBundle(args: { ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), }; - const tierRefs = tierProviderRefs(args.tier, args.settings, { fallbackChain: args.fallbackChain }); - const refs = tierRefs.length > 0 ? prependActiveRef(tierRefs, args.head) : [args.head]; + const refs = args.settings !== undefined + ? prependActiveRef(backupRefsFromSettings(args.settings, [args.head]), args.head) + : [args.head]; const sources = buildSourcesFromRefs(refs, ctx, args.settings); const defaultId = args.head.provider; @@ -261,13 +231,11 @@ export function buildMainSessionSources(args: { reasoningEffort?: ReasoningEffort; sessionId: string; }): { sources: InferenceSource[]; defaultSource: string } { - return buildTieredSourceBundle({ + return buildSourceBundle({ settings: args.settings, catalog: args.catalog, - tier: "standard", head: { provider: args.activeProvider, model: args.activeModel }, sessionId: args.sessionId, - fallbackChain: false, ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), }); } @@ -275,100 +243,15 @@ export function buildMainSessionSources(args: { export function buildSubagentSources(args: { settings: Settings | undefined; catalog: readonly ProviderCatalogEntry[]; - tier: ProviderTier; - head: TierProviderRef; + head: ProviderRef; reasoningEffort?: ReasoningEffort; sessionId?: string; }): { sources: InferenceSource[]; defaultSource: string } { - return buildTieredSourceBundle({ + return buildSourceBundle({ settings: args.settings, catalog: args.catalog, - tier: args.tier, head: args.head, sessionId: args.sessionId ?? randomUUID(), - fallbackChain: true, ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), }); -} - -export function firstTierRef( - tier: ProviderTier, - settings: Settings | undefined, -): TierProviderRef | null { - const refs = tierProviderRefs(tier, settings); - return refs[0] ?? null; -} - -export function tierModeLabel(mode: TierSelectionMode | undefined): string { - return mode === "pin" ? "pin" : "prefer"; -} - -export function formatTierChain(raw: TierDefinition | TierAssignment | undefined): string { - const normalized = normalizeTierDefinition(raw); - if (normalized === undefined || normalized.order.length === 0) return "unset"; - const chain = normalized.order - .map((r) => { - const leg = `${r.provider}/${r.model}`; - return r.reasoningEffort !== undefined ? `${leg}@${r.reasoningEffort}` : leg; - }) - .join(" → "); - return `[${tierModeLabel(normalized.mode)}] ${chain}`; -} - -export function appendTierEntry( - existing: TierDefinition | TierAssignment | undefined, - entry: TierProviderRef, - mode?: TierSelectionMode, -): TierDefinition { - const base = normalizeTierDefinition(existing) ?? { mode: mode ?? "prefer", order: [] }; - const without = base.order.filter((r) => refKey(r) !== refKey(entry)); - return { - mode: mode ?? base.mode ?? "prefer", - order: [entry, ...without], - }; -} - -function tierDefinitionWithOrder( - base: TierDefinition, - order: TierProviderRef[], -): TierDefinition { - const mode: TierSelectionMode = base.mode ?? "prefer"; - return { mode, order }; -} - -export function cycleTierMode(existing: TierDefinition | TierAssignment | undefined): TierDefinition { - const base = normalizeTierDefinition(existing) ?? { mode: "prefer", order: [] }; - const next: TierSelectionMode = base.mode === "pin" ? "prefer" : "pin"; - return tierDefinitionWithOrder({ ...base, mode: next }, base.order); -} - -export function removeTierLeg( - existing: TierDefinition | TierAssignment | undefined, - legIndex: number, -): TierDefinition | undefined { - const base = normalizeTierDefinition(existing); - if (base === undefined || legIndex < 0 || legIndex >= base.order.length) return base; - const order = base.order.filter((_, i) => i !== legIndex); - if (order.length === 0) return undefined; - return tierDefinitionWithOrder(base, order); -} - -export function moveTierLeg( - existing: TierDefinition | TierAssignment | undefined, - legIndex: number, - direction: -1 | 1, -): TierDefinition | undefined { - const base = normalizeTierDefinition(existing); - if (base === undefined) return undefined; - const target = legIndex + direction; - if (target < 0 || target >= base.order.length) return base; - const order = [...base.order]; - const tmp = order[legIndex]; - const swap = order[target]; - if (tmp === undefined || swap === undefined) return base; - order[legIndex] = swap; - order[target] = tmp; - return tierDefinitionWithOrder(base, order); -} - -export { PROVIDER_TIERS }; \ No newline at end of file +} \ No newline at end of file diff --git a/src/config/settings.ts b/src/config/settings.ts index b20b9dd9d..ef78c4428 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -44,18 +44,6 @@ export type ProviderSettings = { opencodeGo?: boolean; }; -export type ProviderTier = "fast" | "standard" | "clever"; -export type TierAssignment = { provider: string; model: string; reasoningEffort?: ReasoningEffort }; -export type TierSelectionMode = "pin" | "prefer"; -export type TierProviderRef = { provider: string; model: string; reasoningEffort?: ReasoningEffort }; -export type TierDefinition = { - mode?: TierSelectionMode; - order: TierProviderRef[]; -}; -export type TierConfig = TierAssignment | TierDefinition; - -export const PROVIDER_TIERS: readonly ProviderTier[] = ["fast", "standard", "clever"]; - // Provider+model identity used by the models-first picker (recent / favorites). export type ModelRef = { provider: string; model: string }; @@ -67,7 +55,6 @@ export type Settings = { defaultProvider?: string; providers: Record; mcpServers?: MCPServerConfig[]; - tiers?: Partial>; // Per-phase model overrides for workflows. Keyed by profile name, then by // workflow step profile key. Example: // { "fast": { "implement": "gpt-4o-mini", "review": "gpt-4o" } } @@ -413,34 +400,15 @@ const ModelRefSchema = type({ model: "string", }); -const TierProviderRefSchema = type({ - provider: "string", - model: "string", - "reasoningEffort?": type.enumerated(...REASONING_EFFORTS), -}); - -const TierAssignmentSchema = TierProviderRefSchema; - -const TierDefinitionSchema = type({ - "mode?": "'pin' | 'prefer'", - order: TierProviderRefSchema.array(), -}); - -const TierConfigSchema = TierDefinitionSchema.or(TierAssignmentSchema); - -const TiersSchema = type({ - "fast?": TierConfigSchema, - "standard?": TierConfigSchema, - "clever?": TierConfigSchema, -}); - const SettingsSchema = type({ "defaultProvider?": "string", providers: type({ "[string]": ProviderSettingsSchema }), // mcpServers accepts both array and object forms, so it is validated by // normalizeMcpServers rather than expressed structurally here. "mcpServers?": "unknown", - "tiers?": TiersSchema, + // Model tiers were removed; an older settings file may still carry this key. + // Accepted and ignored so the file still loads, then dropped on next save. + "tiers?": "unknown", "workflowProfiles?": type({ "[string]": type({ "[string]": "string" }) }), "plugins?": type({ "[string]": type({ "enabled?": "boolean", "consented?": "boolean", "credentials?": type({ "[string]": "string" }) }) }), "pluginPaths?": "string[]", @@ -614,7 +582,6 @@ type OptionalLocalSettingsFields = { export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "defaultProvider", "mcpServers", - "tiers", "workflowProfiles", "plugins", "pluginPaths", @@ -713,11 +680,15 @@ export async function loadSettings(path: string): Promise { `settings: "workflowPlugins"/"agentPlugins" are no longer supported and will be dropped. Install those plugins under .corbits/plugins/ (or via /plugins "add by path") and enable them in /plugins.\n`, ); } + if (s.tiers !== undefined) { + process.stderr.write( + `settings: "tiers" is no longer supported and will be dropped. Model tiers were removed; use /model to pick a provider and model directly.\n`, + ); + } // Transforms (normalize/clamp/enum) first; pickDefined only drops undefined. const optional: OptionalSettingsFields = { defaultProvider: s.defaultProvider as string | undefined, mcpServers: s.mcpServers !== undefined ? normalizeMcpServers(s.mcpServers) : undefined, - tiers: s.tiers as Settings["tiers"] | undefined, workflowProfiles: s.workflowProfiles as Settings["workflowProfiles"] | undefined, plugins: s.plugins as Settings["plugins"] | undefined, pluginPaths: s.pluginPaths as string[] | undefined, @@ -1144,69 +1115,6 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { }; } -function isTierDefinitionConfig(raw: TierConfig): raw is TierDefinition { - return "order" in raw && Array.isArray(raw.order); -} - -function tierConfigToDefinition(raw: TierConfig): TierDefinition | null { - if (isTierDefinitionConfig(raw)) { - const order = raw.order.filter((r) => r.provider.length > 0 && r.model.length > 0); - if (order.length === 0) return null; - return { mode: raw.mode ?? "prefer", order }; - } - const leg = raw; - if (leg.provider.length === 0 || leg.model.length === 0) return null; - return { mode: "pin", order: [{ provider: leg.provider, model: leg.model }] }; -} - -/** Tier config at the given name only (no fast → standard → clever walk). */ -export function tierDefinitionAt( - tier: ProviderTier, - settings: Settings, -): TierDefinition | null { - const raw = settings.tiers?.[tier]; - if (raw === undefined) return null; - const def = tierConfigToDefinition(raw); - if (def === null) return null; - const viable = def.order.filter((r) => settings.providers[r.provider] !== undefined); - if (viable.length === 0) return null; - return { mode: def.mode ?? "prefer", order: viable }; -} - -export function resolveTierDefinition( - tier: ProviderTier, - settings: Settings, -): TierDefinition | null { - const chain: ProviderTier[] = ["fast", "standard", "clever"]; - const start = chain.indexOf(tier); - if (start === -1) return null; - for (let i = start; i < chain.length; i++) { - const t = chain[i] as ProviderTier; - const raw = settings.tiers?.[t]; - if (raw === undefined) continue; - const def = tierConfigToDefinition(raw); - if (def === null) continue; - const viable = def.order.filter((r) => settings.providers[r.provider] !== undefined); - if (viable.length === 0) continue; - const mode: TierSelectionMode = def.mode ?? "prefer"; - return { mode, order: viable }; - } - return null; -} - -// Walk the fallback chain fast → standard → clever and return the first -// provider/model in the resolved tier chain. -export function resolveTier(tier: ProviderTier, settings: Settings): TierAssignment | null { - const def = resolveTierDefinition(tier, settings); - const first = def?.order[0]; - if (first === undefined) return null; - return { - provider: first.provider, - model: first.model, - ...(first.reasoningEffort !== undefined ? { reasoningEffort: first.reasoningEffort } : {}), - }; -} - import type { InferenceSpec } from "../agent/profile-types.js"; // A resolved inference leg, with reasoningEffort threaded through. @@ -1231,7 +1139,7 @@ function isLegViable(leg: { provider: string; model: string }, settings: Setting // // - "resolved" — a viable leg was found, returned in `value`. // - "fallback" — no viable leg, but the agent permits fallback (the -// caller falls through to tier / active session). +// caller falls through to the active session's model). // - "unavailable" — no viable leg, and the spec forbids fallback // (`mode: "pin"` or `agentModelFallback: "none"`). The // caller must surface this as an error rather than diff --git a/src/plugins/admin.ts b/src/plugins/admin.ts index 0f4e465a0..88a0c4f95 100644 --- a/src/plugins/admin.ts +++ b/src/plugins/admin.ts @@ -8,8 +8,8 @@ export type PluginDescriptor = { description?: string; credentials: PluginCredentialField[]; // For kind:"agent" plugins — the profiles contributed, shown so the user can - // see which sub-agents and tiers a plugin provides before enabling it. - agentProfiles?: { id: string; tier?: string; description?: string }[]; + // see which sub-agents a plugin provides before enabling it. + agentProfiles?: { id: string; description?: string }[]; /** * True when discovery found the plugin but code is not imported yet (project * or path origin still untrusted). Enabling records trust and full-loads. diff --git a/src/plugins/agent-plugins.test.ts b/src/plugins/agent-plugins.test.ts index e8468397a..71272ce99 100644 --- a/src/plugins/agent-plugins.test.ts +++ b/src/plugins/agent-plugins.test.ts @@ -20,7 +20,6 @@ function agentModule( const validProfile = { id: "explorer", description: "Repository exploration sub-agent", - tier: "fast" as const, capabilities: { mode: "allow" as const, tools: ["read_file", "search_files", "grep"] }, systemPromptRole: "You explore repositories.", }; @@ -31,7 +30,6 @@ describe("resolveAgentPluginProfiles", () => { const profiles = await resolveAgentPluginProfiles([mod], config); expect(profiles.length).toBe(1); expect(profiles[0]!.id).toBe("explorer"); - expect(profiles[0]!.tier).toBe("fast"); }); test("skips profiles from disabled plugins", async () => { @@ -57,7 +55,7 @@ describe("resolveAgentPluginProfiles", () => { test("skips malformed profiles, keeps valid ones", async () => { const { mod, config } = agentModule("p1", [ validProfile, - { id: "bad", tier: "nonexistent" }, // invalid tier + { id: "bad", maxTurns: "nonexistent" }, // invalid maxTurns type { description: "missing id" }, // missing required id ]); const profiles = await resolveAgentPluginProfiles([mod], config); diff --git a/src/plugins/data-only-agent.ts b/src/plugins/data-only-agent.ts index 05e4dfe01..be73950d5 100644 --- a/src/plugins/data-only-agent.ts +++ b/src/plugins/data-only-agent.ts @@ -52,7 +52,7 @@ const NativeCapabilitiesModeSchema = type("'allow' | 'exclude'"); // (legacy: tools: { read: true, bash: false }) // - corbitsdev: name, description, mode, color, permission: { read: "allow", bash: "deny" } // -// Native Corbits Code keys also work and win ties: tier, inference, capabilities, +// Native Corbits Code keys also work and win ties: inference, capabilities, // skills (frontmatter list, in addition to body `Load the X skill` lines). // Upstream tool-name aliases mapped to Corbits Code tool ids. Case-insensitive. @@ -229,12 +229,16 @@ function normalizePermission( return undefined; } -// Normalize the union of `tier` / `model` / `effort` / `inference` shapes into -// either a tier alias or an explicit InferenceSpec. Native `inference` wins; -// then `tier`; then `model` (object or array) with optional `effort`. +// Normalize the union of `model` / `effort` / `inference` shapes into an +// explicit InferenceSpec. Native `inference` wins; then `model` (object or +// array) with optional `effort` applied to legs that don't declare their own. +// +// A bare Claude Code `effort: high` with no `model` has nothing to attach the +// effort to now that tiers (which used to map effort to a model swap) are +// gone, so it is ignored — set `model` alongside `effort` to pin both. function normalizeInference( fm: Record | null, -): { tier?: "fast" | "standard" | "clever"; inference?: InferenceSpec } { +): { inference?: InferenceSpec } { if (fm === null) return {}; // Native explicit inference spec. @@ -247,22 +251,6 @@ function normalizeInference( if (spec !== undefined) return { inference: spec }; } - // Native tier alias. - if (fm.tier === "fast" || fm.tier === "standard" || fm.tier === "clever") { - return { tier: fm.tier }; - } - - // Claude Code `effort: high` — maps to a tier alias. - if (typeof fm.effort === "string") { - const tierFromEffort: Record = { - low: "fast", - medium: "standard", - high: "clever", - }; - const tier = tierFromEffort[fm.effort]; - if (tier !== undefined) return { tier }; - } - // `model` block: object, array, or (rejected in v1) string. if (fm.model !== undefined) { const spec = normalizeModelField(fm.model, fm.effort); @@ -460,12 +448,11 @@ export async function loadDataOnlyAgentPlugin( // to JS-plugin agents too. const systemPromptRole = promptBody; - const { tier, inference } = normalizeInference(frontmatter); + const { inference } = normalizeInference(frontmatter); const capabilities = normalizeCapabilities(frontmatter); const profile: Record = { id }; if (description !== undefined) profile.description = description; - if (tier !== undefined) profile.tier = tier; if (inference !== undefined) profile.inference = inference; if (capabilities !== undefined) profile.capabilities = capabilities; // `orchestrator: true` opts the agent into the recursion exception. Stored diff --git a/src/provider/context-window.test.ts b/src/provider/context-window.test.ts new file mode 100644 index 000000000..76b9fe50b --- /dev/null +++ b/src/provider/context-window.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, afterEach } from "bun:test"; +import { + contextWindowFor, + hasContextWindowFor, + setModelContextWindows, +} from "./context-window.js"; + +describe("contextWindowFor", () => { + afterEach(() => { + setModelContextWindows(undefined); + }); + + it("resolves a custom-provider-prefixed id against the bare model registry entry", () => { + setModelContextWindows({ "grok-4.5": 500_000 }); + expect(contextWindowFor("xai/thegreataxios:grok-4.5")).toBe(500_000); + }); + + it("resolves a custom-provider-prefixed id against the canonical provider/model entry", () => { + setModelContextWindows({ "xai/grok-4.5": 500_000 }); + expect(contextWindowFor("xai/thegreataxios:grok-4.5")).toBe(500_000); + }); + + it("falls back to a grok/xai heuristic window when the registry has no entry", () => { + setModelContextWindows(undefined); + expect(contextWindowFor("xai/thegreataxios:grok-4.5")).toBe(256_000); + }); + + it("reports low confidence when a miss falls through to the heuristic", () => { + setModelContextWindows(undefined); + expect(hasContextWindowFor("xai/thegreataxios:grok-4.5")).toBe(false); + }); + + it("reports confidence when the registry has a matching entry", () => { + setModelContextWindows({ "grok-4.5": 500_000 }); + expect(hasContextWindowFor("xai/thegreataxios:grok-4.5")).toBe(true); + }); +}); diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 935c4776c..03e22b0b8 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -35,12 +35,38 @@ function heuristicWindow(model: string): number { if (m.includes("deepseek")) return 128_000; if (m.includes("glm")) return 200_000; if (m.includes("o3") || m.includes("o4")) return 200_000; + if (m.includes("grok") || m.includes("xai")) return 256_000; return DEFAULT_CONTEXT_WINDOW; } +// Model identity is `provider:model` (model-catalog.ts), and `provider` may +// itself be a custom account name (`xai/thegreataxios`) rather than the +// canonical provider models.dev publishes under (`xai`). Try, in order: the +// full identity as given, the bare model id, and `canonicalProvider/model` — +// so a custom-named provider still exact-matches the registry instead of +// silently missing and falling through to the heuristic. +function lookupCandidates(model: string): string[] { + const colonIndex = model.indexOf(":"); + if (colonIndex === -1) return [model]; + + const providerSegment = model.slice(0, colonIndex); + const bareModel = model.slice(colonIndex + 1); + const canonicalProvider = providerSegment.split("/")[0]; + + return [model, bareModel, `${canonicalProvider}/${bareModel}`]; +} + +/** True when the registry has an entry for `model` under any known form, so a + * caller can distinguish a confident lookup from the heuristic fallback. */ +export function hasContextWindowFor(model: string): boolean { + return lookupCandidates(model).some((candidate) => contextWindowRegistry[candidate] !== undefined); +} + export function contextWindowFor(model: string): number { - const exact = contextWindowRegistry[model]; - if (exact !== undefined) return exact; + for (const candidate of lookupCandidates(model)) { + const exact = contextWindowRegistry[candidate]; + if (exact !== undefined) return exact; + } return heuristicWindow(model); } diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 261585e50..c72443371 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -604,7 +604,7 @@ describe("sub-agent stop helpers", () => { expect(parsed.summary).toContain("degenerate repetition"); expect(parsed.findings).toContain("dig footer/chrome"); expect(parsed.blockers).toContain("will be refused"); - expect(parsed.blockers).toContain("not maxTurns or tier alone"); + expect(parsed.blockers).toContain("not maxTurns alone"); const hinted = appendSubAgentParentHints(report); expect(hinted).toContain("Do not re-dispatch the identical brief"); }); @@ -1035,17 +1035,12 @@ describe("createTaskTool", () => { expect(captured?.maxTurns).toBe(50); }); - test("task tier rebuilds provider from settings and wins over profile inference", async () => { + test("profile inference rebuilds provider from settings", async () => { let captured: RunSubAgentParams | undefined; const settings = { providers: { - "clever-p": { baseURL: "http://clever", apiKey: "k", models: ["clever-model"] }, "profile-p": { baseURL: "http://profile", apiKey: "k", models: ["profile-model", "pinned-model"] }, }, - tiers: { - clever: { provider: "clever-p", model: "clever-model" }, - standard: { provider: "profile-p", model: "profile-model" }, - }, }; const tool = createTaskTool({ permissionGate: testPermissionGate, @@ -1056,7 +1051,6 @@ describe("createTaskTool", () => { profiles: [ { id: "deep", - tier: "standard", inference: { order: [{ provider: "profile-p", model: "pinned-model" }] }, }, ], @@ -1066,157 +1060,53 @@ describe("createTaskTool", () => { }, }); await callTask(tool, { - description: "tier-override", + description: "profile-inference", prompt: "x", agent: "deep", - tier: "clever", - }); - expect(captured?.provider.providerName).toBe("clever-p"); - expect(captured?.provider.model).toBe("clever-model"); - expect(captured?.tier).toBe("clever"); - }); - - test("profile tier still applies when task omits tier", async () => { - let captured: RunSubAgentParams | undefined; - const settings = { - providers: { - "profile-p": { baseURL: "http://profile", apiKey: "k", models: ["profile-model"] }, - }, - tiers: { - standard: { provider: "profile-p", model: "profile-model" }, - }, - }; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings, - profiles: [{ id: "deep", tier: "standard" }], - run: async (params) => { - captured = params; - return "done"; - }, }); - await callTask(tool, { description: "profile-tier", prompt: "x", agent: "deep" }); expect(captured?.provider.providerName).toBe("profile-p"); - expect(captured?.provider.model).toBe("profile-model"); - expect(captured?.tier).toBe("standard"); + expect(captured?.provider.model).toBe("pinned-model"); }); - test("unconfigured task tier fails closed", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: { providers: {} }, - run: async () => "done", - }); - const out = await callTask(tool, { - description: "bad-tier", - prompt: "x", - tier: "clever", - }); - expect(out).toContain("Error:"); - expect(out).toContain("clever"); - expect(out).toContain("not configured"); - }); - - test("task tier targeting OAuth provider resolves via live catalog", async () => { + test("profile inference targeting OAuth provider resolves via live catalog", async () => { let captured: RunSubAgentParams | undefined; - // Realistic disk shape: OAuth never lands in settings.json. Tiers name - // xAI; only the live catalog supplies the provider credentials. const diskSettings = { providers: {}, - tiers: { - clever: { provider: "xai/work", model: "grok-4" }, - standard: { provider: "xai/work", model: "grok-3" }, - fast: { provider: "xai/work", model: "grok-3-mini" }, - }, }; const catalog = [ - { - name: "codex/home", - baseURL: "https://chatgpt.com/backend-api", - apiKey: "codex-token", - models: ["gpt-5.3-codex"], - codexProfile: "home", - }, { name: "xai/work", baseURL: "https://api.x.ai/v1", apiKey: "xai-token", - models: ["grok-4", "grok-3", "grok-3-mini"], + models: ["grok-4"], xaiProfile: "work", }, ]; - const parentProvider = { - providerName: "codex/home", - baseURL: "https://chatgpt.com/backend-api", - apiKey: "codex-token", - model: "gpt-5.3-codex", - }; const tool = createTaskTool({ permissionGate: testPermissionGate, cwd: "/repo", getWorkdirBase: () => "/repo/.corbits", - provider: parentProvider, + provider, settings: diskSettings, catalog, + profiles: [ + { + id: "deep", + inference: { order: [{ provider: "xai/work", model: "grok-4" }] }, + }, + ], run: async (params) => { captured = params; return "done"; }, }); - await callTask(tool, { - description: "oauth-tier", - prompt: "x", - tier: "clever", - }); + await callTask(tool, { description: "oauth-profile-inference", prompt: "x", agent: "deep" }); expect(captured?.provider.providerName).toBe("xai/work"); expect(captured?.provider.model).toBe("grok-4"); expect(captured?.provider.apiKey).toBe("xai-token"); - expect(captured?.tier).toBe("clever"); }); - test("profile tier targeting OAuth resolves via live catalog", async () => { - let captured: RunSubAgentParams | undefined; - const diskSettings = { - providers: {}, - tiers: { - standard: { provider: "xai/work", model: "grok-3" }, - }, - }; - const catalog = [ - { - name: "xai/work", - baseURL: "https://api.x.ai/v1", - apiKey: "xai-token", - models: ["grok-3"], - xaiProfile: "work", - }, - ]; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: diskSettings, - catalog, - profiles: [{ id: "deep", tier: "standard" }], - run: async (params) => { - captured = params; - return "done"; - }, - }); - await callTask(tool, { description: "oauth-profile-tier", prompt: "x", agent: "deep" }); - expect(captured?.provider.providerName).toBe("xai/work"); - expect(captured?.provider.model).toBe("grok-3"); - expect(captured?.tier).toBe("standard"); - }); - - test("task tier targeting OAuth fails closed when catalog lacks the provider", async () => { + test("profile inference fails closed when pinned and unavailable", async () => { const tool = createTaskTool({ permissionGate: testPermissionGate, cwd: "/repo", @@ -1226,16 +1116,11 @@ describe("createTaskTool", () => { providers: { "api-only": { baseURL: "http://api", apiKey: "k", models: ["m"] }, }, - tiers: { - clever: { provider: "xai/missing", model: "grok-4" }, - }, }, - catalog: [ + profiles: [ { - name: "api-only", - baseURL: "http://api", - apiKey: "k", - models: ["m"], + id: "deep", + inference: { mode: "pin", order: [{ provider: "xai/missing", model: "grok-4" }] }, }, ], run: async () => "done", @@ -1243,10 +1128,10 @@ describe("createTaskTool", () => { const out = await callTask(tool, { description: "missing-oauth", prompt: "x", - tier: "clever", + agent: "deep", }); expect(out).toContain("Error:"); - expect(out).toContain("not configured"); + expect(out).toContain("unavailable"); }); test("rejects task maxTurns above the cap", async () => { diff --git a/src/subagent/run.ts b/src/subagent/run.ts index af3edc865..c2e279cb8 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -453,11 +453,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { const head = { provider: params.provider.providerName, model: params.provider.model }; const bundle = - params.tier !== undefined && params.settings !== undefined && params.catalog !== undefined + params.settings !== undefined && params.catalog !== undefined ? buildSubagentSources({ settings: params.settings, catalog: params.catalog, - tier: params.tier, head, ...(params.provider.reasoningEffort !== undefined ? { reasoningEffort: params.provider.reasoningEffort } diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 62194a0be..38c77513a 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -281,7 +281,7 @@ export function forcedStopReport( : reason === "stalled" ? "Leaf went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish or check on the background work directly." : reason === "repetition" - ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns or tier alone." + ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns alone." : "Leaf turn budget exhausted; parent may re-dispatch for remaining work."; // Demote nested report-section headings so runSubAgent's parse/format pass // cannot clobber this outer Summary/Blockers with an agent-shaped envelope @@ -346,7 +346,7 @@ const THRASH_PARENT_HINT = "[Sub-agent stopped for progressive thrash (re-read pressure). Do not re-dispatch the identical brief (it will be refused) — change scope, success_criteria, and do_not; continue from Findings.]"; const REPETITION_PARENT_HINT = - "[Sub-agent aborted after its streamed output degenerated into a loop. Do not re-dispatch the identical brief — it will be refused and would likely loop again; change prompt, intent, success_criteria, do_not, and/or agent (tier alone does not change the fingerprint).]"; + "[Sub-agent aborted after its streamed output degenerated into a loop. Do not re-dispatch the identical brief — it will be refused and would likely loop again; change prompt, intent, success_criteria, do_not, and/or agent (maxTurns alone does not change the fingerprint).]"; const NO_PROGRESS_PARENT_HINT = "[Sub-agent stopped for no-progress (identical tool-call fingerprint). Do not re-dispatch the identical brief (it will be refused) — tighten success_criteria and do_not, or change approach.]"; diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 4063f3134..0a481f28d 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -14,10 +14,9 @@ import type { import { runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../config/index.js"; import { formatSubAgentTaskAuthFailureMessage } from "./inference-auth-failure.js"; import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js"; -import type { Settings, ProviderTier } from "../config/settings.js"; +import type { Settings } from "../config/settings.js"; import { resolveSubAgentMaxTurns, - resolveTier, resolveInferenceWithPolicy, validateTaskMaxTurns, } from "../config/settings.js"; @@ -63,14 +62,13 @@ export const TaskToolArgs = type({ "do_not?": "string[]", "report_focus?": "string", "maxTurns?": "number", - "tier?": "'fast' | 'standard' | 'clever'", }); export const taskToolDefinition: ToolDefinition = { name: "task", description: - "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns or tier alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", + "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", inputSchema: { type: "object", properties: { @@ -118,19 +116,13 @@ export const taskToolDefinition: ToolDefinition = { agent: { type: "string", description: - "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify tier, capability restrictions, and role. Role drives reasoning-effort defaults (orchestrator high, leaf medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", + "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify capability restrictions and role. Role drives reasoning-effort defaults (orchestrator high, leaf medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", }, maxTurns: { type: "number", description: "Optional inference-turn budget for this worker only (not the parent session limit). Defaults to settings or 30; hard cap 100.", }, - tier: { - type: "string", - enum: ["fast", "standard", "clever"], - description: - "Optional provider tier override for this spawn only (fast | standard | clever). Wins over profile inference and profile tier; fails closed when the tier is unconfigured.", - }, }, required: ["description", "prompt"], }, @@ -209,7 +201,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { do_not: rawDoNot, report_focus: rawReportFocus, maxTurns: rawMaxTurns, - tier: rawTaskTier, } = parsed; const description = rawDesc.trim(); const context = rawCtx?.trim(); @@ -234,21 +225,20 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { let provider: SubAgentProvider = typeof deps.provider === "function" ? deps.provider() : deps.provider; - // Snapshot parent effort before profile/tier rebuilds so role-default + // Snapshot parent effort before profile-inference rebuilds so role-default // resolution can fall back to inheritance without reading a mutated provider. const parentEffort = provider.reasoningEffort; - // Explicit profile inference / task-tier pin (if any). Distinct from the - // parent snapshot so resolveEffortForRole can apply pin > role > parent. + // Explicit profile inference pin (if any). Distinct from the parent + // snapshot so resolveEffortForRole can apply pin > role > parent. let effortPin: ReasoningEffort | undefined; let capabilities: CapabilityFilter | undefined; let systemPromptRole: string | undefined; let orchestrator = false; - let tier: ProviderTier | undefined; let profileMaxTurns: number | undefined; const diskSettings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; // OAuth providers live in the live catalog, not settings.json. Overlay so - // tier/inference resolution can target Codex/xAI the same way the TUI does. + // inference resolution can target Codex/xAI the same way the TUI does. const settings = catalog !== undefined ? runtimeSettingsWithCatalog(diskSettings, catalog) @@ -256,8 +246,8 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { const profiles = deps.profiles !== undefined ? resolveDep(deps.profiles) : undefined; // Rebuild provider from a resolved provider/model assignment. Shared by - // task(tier=), profile.inference, and profile.tier so fail-closed effort - // validation and settings lookup stay consistent. + // profile.inference so fail-closed effort validation and settings + // lookup stay consistent. const applyResolvedProvider = ( resolved: { provider: string; @@ -338,64 +328,23 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (profile.orchestrator === true && deps.allowOrchestrator !== false) { orchestrator = true; } - // Profile inference/tier apply only when task(tier=) is omitted — the - // caller override wins so a cheap/fast dispatch can still use a clever - // profile's tools without paying for the profile's pinned model. - if (rawTaskTier === undefined && settings !== undefined) { - // Per-agent pinned inference (provider/model/effort) wins over the - // tier alias when both are declared. Resolution uses policy - // (mode: pin / agentModelFallback: none) so a forbidden fallback - // surfaces as a dispatch error rather than silently running on the - // parent's provider. - let resolved: - | { provider: string; model: string; reasoningEffort?: ReasoningEffort } - | null = null; - if (profile.inference !== undefined) { - const outcome = resolveInferenceWithPolicy(profile.inference, settings); - if (outcome.kind === "unavailable") { - return taskToolResult( - call.id, - `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, - ); - } - if (outcome.kind === "resolved") resolved = outcome.value; - } - if (resolved === null && profile.tier !== undefined) { - const assignment = resolveTier(profile.tier as ProviderTier, settings); - if (assignment !== null) { - resolved = assignment; - } + // Per-agent pinned inference (provider/model/effort), if declared. + // Resolution uses policy (mode: pin / agentModelFallback: none) so a + // forbidden fallback surfaces as a dispatch error rather than + // silently running on the parent's provider. + if (profile.inference !== undefined && settings !== undefined) { + const outcome = resolveInferenceWithPolicy(profile.inference, settings); + if (outcome.kind === "unavailable") { + return taskToolResult( + call.id, + `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + ); } - if (resolved !== null) { - const err = applyResolvedProvider(resolved, `agent "${agentId}"`); + if (outcome.kind === "resolved") { + const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); if (err !== null) return taskToolResult(call.id, err); } - if (profile.tier !== undefined) { - tier = profile.tier as ProviderTier; - } - } - } - - // task(tier=) is highest precedence: overrides profile inference/tier and - // the parent provider. Fail closed when settings or the tier chain is missing. - if (rawTaskTier !== undefined) { - const taskTier = rawTaskTier as ProviderTier; - if (settings === undefined) { - return taskToolResult( - call.id, - `Error: task tier "${taskTier}" requires configured settings.providers.`, - ); - } - const assignment = resolveTier(taskTier, settings); - if (assignment === null) { - return taskToolResult( - call.id, - `Error: task tier "${taskTier}" is not configured. Set settings.tiers.${taskTier} (or the legacy tier assignment) before dispatching.`, - ); } - const err = applyResolvedProvider(assignment, `task tier "${taskTier}"`); - if (err !== null) return taskToolResult(call.id, err); - tier = taskTier; } // Role-based effort: pin > orchestrator/leaf default > parent inheritance. @@ -578,7 +527,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { cwd: worktreeCwd ?? deps.cwd, workdirBase: deps.getWorkdirBase(), provider, - ...(tier !== undefined ? { tier } : {}), ...(settings !== undefined ? { settings } : {}), ...(catalog !== undefined ? { catalog } : {}), description, diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 002f497fe..2599842e0 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -10,7 +10,7 @@ import type { ToolPlugin } from "@intx/tools-posix"; import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js"; import type { ProviderCatalogEntry } from "../config/index.js"; -import type { Settings, ProviderTier } from "../config/settings.js"; +import type { Settings } from "../config/settings.js"; import type { ShellTimeoutConfig } from "../plugins/shell-guard-plugin.js"; import type { PermissionGate } from "../permission/gate.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; @@ -27,9 +27,9 @@ export type SubAgentProvider = { // resolveEffortForRole — leaves default to medium, orchestrators to high, // so a primary /agent high selection does not force every leaf onto high. reasoningEffort?: ReasoningEffort; - // Mirrors ProviderCatalogEntry.bifrostVirtualKey. Without it the generic - // (no-tier) dispatch path builds a plain openai-compatible source and the - // gateway never receives the x-bf-vk header. + // Mirrors ProviderCatalogEntry.bifrostVirtualKey. Without it the dispatch + // path builds a plain openai-compatible source and the gateway never + // receives the x-bf-vk header. bifrostVirtualKey?: boolean; }; @@ -71,7 +71,6 @@ export type RunSubAgentParams = { cwd: string; workdirBase: string; provider: SubAgentProvider; - tier?: ProviderTier; settings?: Settings; catalog?: readonly ProviderCatalogEntry[]; description: string; diff --git a/src/tui-opentui/command-surfaces.ts b/src/tui-opentui/command-surfaces.ts index 72e77ccfa..61a45a01f 100644 --- a/src/tui-opentui/command-surfaces.ts +++ b/src/tui-opentui/command-surfaces.ts @@ -52,7 +52,7 @@ export type PluginEntry = { readonly canRevokeTrust?: boolean readonly credentials: readonly PluginCredentialFieldEntry[] readonly credentialValues: Readonly> - readonly agentProfiles?: readonly { readonly id: string; readonly tier?: string; readonly description?: string }[] + readonly agentProfiles?: readonly { readonly id: string; readonly description?: string }[] /** Absolute path an untrusted path-origin plugin was discovered at. */ readonly originPath?: string } diff --git a/src/tui-opentui/model-catalog.ts b/src/tui-opentui/model-catalog.ts index 0be91c472..d4d0f4f6b 100644 --- a/src/tui-opentui/model-catalog.ts +++ b/src/tui-opentui/model-catalog.ts @@ -12,7 +12,7 @@ import { isGoModelOnZenPath as defaultIsGoModelOnZenPath } from "../provider/billing-product.js" import { getActivePricingCache } from "../cost/cost-visibility.js" import { lookupModelPricing, type PricingCache } from "../cost/pricing-fetcher.js" -import { contextWindowFor } from "../provider/context-window.js" +import { contextWindowFor, hasContextWindowFor } from "../provider/context-window.js" import { modelReasoningCapability } from "../provider/reasoning-effort.js" import type { ItemDescription } from "./shell.js" @@ -272,9 +272,12 @@ function pricingImpact(pricing: PricingCache | null, model: string): string { function whatLine(model: string): string { const reasoning = modelReasoningCapability(model) const context = contextWindowFor(model) - const contextText = context > 0 ? `${Math.round(context / 1000)}k context` : "context length unknown" - const tierText = reasoning === true ? "deep reasoning" : reasoning === false ? "standard tier" : "tier unknown" - return `${tierText}. ${contextText}.` + const confident = hasContextWindowFor(model) + const contextText = context > 0 + ? `${Math.round(context / 1000)}k context${confident ? "" : " (estimated)"}` + : "context length unknown" + const reasoningText = reasoning === true ? "Deep reasoning" : reasoning === false ? "No extended reasoning" : "Reasoning support unknown" + return `${reasoningText}. ${contextText}.` } /** diff --git a/src/tui-opentui/overlays.ts b/src/tui-opentui/overlays.ts index 91ef61f39..b8e20c83e 100644 --- a/src/tui-opentui/overlays.ts +++ b/src/tui-opentui/overlays.ts @@ -170,6 +170,8 @@ export type OpenModelPickerOpts = { readonly describe?: (itemId: string) => ItemDescription | null /** Bare-key claim on the focused row (e.g. `f` to toggle favorite). */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean + /** Per-open Esc/dismiss — the provider-first picker steps back to the provider level instead of closing outright. */ + readonly onCancel?: () => void } export function openModelPickerOverlay( @@ -186,5 +188,6 @@ export function openModelPickerOverlay( ...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), ...(opts?.describe !== undefined ? { describe: opts.describe } : {}), ...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}), + ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), }) } diff --git a/src/tui-opentui/product-host.test.ts b/src/tui-opentui/product-host.test.ts index af0ec75ff..daa4c08cf 100644 --- a/src/tui-opentui/product-host.test.ts +++ b/src/tui-opentui/product-host.test.ts @@ -6,13 +6,14 @@ import { EventEmitter } from "node:events" import { describe, expect, test } from "bun:test" import type { PermissionRequest } from "../permission/types.js" import { createHarness } from "./harness.js" -import { acceptOverlaySelection } from "./shell.js" +import { acceptOverlaySelection, closeInsetOverlay, moveOverlaySelection } from "./shell.js" import { mountProductHost, operatorResultFromSelection, permissionChoices, type ProductHostConfig, } from "./product-host.js" +import { buildModelsFirstCatalog } from "./model-catalog.js" function makeFakeSessionPort(): { readonly sends: string[] @@ -271,6 +272,162 @@ describe("mountProductHost", () => { }) }) +describe("provider-first model picker", () => { + // Mirrors the bug-report shape: several providers, one (codex) with three + // accounts, plus a favorite so the top level has a reachable-without-descending pick. + const providers = { + "codex/abk-labs": { models: ["gpt-5.5", "gpt-5.6-sol"] }, + "codex/dirtroad": { models: ["gpt-5.5", "gpt-5.6-sol"] }, + "codex/fleur": { models: ["gpt-5.5", "gpt-5.6-sol"] }, + "xai/thegreataxios": { models: ["grok-4.5"] }, + "Z.AI": { models: ["glm-5", "glm-5-turbo", "glm-5.2"] }, + } + + async function mountPicker(overrides: Partial = {}) { + const harness = await createHarness({ width: 80, height: 24 }) + const port = makeFakeSessionPort() + const catalog = buildModelsFirstCatalog({ + providers, + favorites: [{ provider: "codex/abk-labs", model: "gpt-5.5" }], + }) + const selected: string[] = [] + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + onModelSelect: (id) => selected.push(id), + ...overrides, + }) + return { harness, host, selected } + } + + test("top level lists providers (one row per account), not one row per model", async () => { + const { harness, host } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + // Each codex account is its own row; the account name appears once, + // not once per model it exposes. + expect(frame).toContain("codex/abk-labs") + expect(frame).toContain("codex/dirtroad") + expect(frame).toContain("codex/fleur") + expect(frame).toContain("xai/thegreataxios") + // The favorite is a leaf row, reachable without descending — it, not + // its provider group, carries the model name at the top level. + expect(frame).toContain("gpt-5.5") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("selecting a provider descends into its models; Escape returns to the provider level", async () => { + const { harness, host } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + + const items = host.shell.overlayItems + const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios")) + expect(xaiIndex).toBeGreaterThanOrEqual(0) + moveOverlaySelection(host.shell, xaiIndex) + acceptOverlaySelection(host.shell) + await harness.renderOnce() + + const modelFrame = harness.captureCharFrame() + expect(modelFrame).toContain("grok-4.5") + expect(modelFrame).not.toContain("codex/abk-labs") + + closeInsetOverlay(host.shell) + await harness.renderOnce() + const backFrame = harness.captureCharFrame() + expect(backFrame).toContain("codex/abk-labs") + expect(host.shell.overlayList).not.toBeNull() + } finally { + host.dispose() + harness.destroy() + } + }) + + test("selecting a model at the model level applies the pick", async () => { + const { harness, host, selected } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + const items = host.shell.overlayItems + const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios")) + moveOverlaySelection(host.shell, xaiIndex) + acceptOverlaySelection(host.shell) + await harness.renderOnce() + + acceptOverlaySelection(host.shell) + expect(selected).toEqual(["xai/thegreataxios:grok-4.5"]) + } finally { + host.dispose() + harness.destroy() + } + }) + + test("the current model's row reads \"(current)\" at a glance", async () => { + const harness = await createHarness({ width: 80, height: 24 }) + const port = makeFakeSessionPort() + const catalog = buildModelsFirstCatalog({ providers, recent: [{ provider: "xai/thegreataxios", model: "grok-4.5" }] }) + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + onModelSelect: () => {}, + }) + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + expect(frame).toContain("xai/thegreataxios / grok-4.5 (current)") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("fits and scrolls within a short terminal instead of overflowing it", async () => { + const port = makeFakeSessionPort() + const harness = await createHarness({ width: 80, height: 10 }) + try { + const catalog = buildModelsFirstCatalog({ providers }) + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + onModelSelect: () => {}, + }) + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + // Five provider rows do not all fit a 10-row terminal alongside the + // overlay chrome; the picker renders without throwing and the frame + // stays within the terminal's own line count. + expect(frame.replace(/\n$/, "").split("\n").length).toBeLessThanOrEqual(10) + expect(host.shell.overlayList).not.toBeNull() + } finally { + host.dispose() + } + } finally { + harness.destroy() + } + }) +}) + describe("mount failure", () => { test("destroys the renderer when gate wiring throws", async () => { const harness = await createHarness({ width: 80, height: 24 }) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index de6bf83ad..1af9cc838 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -58,6 +58,72 @@ import type { StreamRow } from "./stream.js" import type { PendingImageAttachment } from "../tui/image-attachments.js" +const PROVIDER_GROUP_PREFIX = "providerGroup:" + +function providerGroupRowId(provider: string): string { + return `${PROVIDER_GROUP_PREFIX}${provider}` +} + +function providerFromGroupRowId(id: string): string | null { + return id.startsWith(PROVIDER_GROUP_PREFIX) ? id.slice(PROVIDER_GROUP_PREFIX.length) : null +} + +/** Provider (account) segment of a `provider:model` row id. */ +function providerOfRowId(id: string): string { + const i = id.indexOf(":") + return i === -1 ? id : id.slice(0, i) +} + +/** Provider label segment of a `Provider Label / model` row label. */ +function providerLabelOfRow(label: string): string { + const i = label.indexOf(" / ") + return i === -1 ? label : label.slice(0, i) +} + +type ModelGroup = { + readonly label: string + readonly rows: ProductHostModelOption[] +} + +/** + * Split a flat, section-tagged models list into the provider-first picker's + * top level (recent/favorites/unconnected pass through flat; each distinct + * provider collapses into one group row, in first-seen order) plus the + * per-provider model rows reached by descending into a group. Rows with no + * `section` (a caller not using buildModelsFirstCatalog) pass through + * ungrouped, preserving today's single-level picker for that caller. + */ +function groupModelsForPicker( + models: readonly ProductHostModelOption[], +): { readonly top: ProductHostModelOption[]; readonly groups: ReadonlyMap } { + const top: ProductHostModelOption[] = [] + const groups = new Map() + for (const row of models) { + if (row.section !== "provider") { + top.push(row) + continue + } + const provider = providerOfRowId(row.id) + let group = groups.get(provider) + if (group === undefined) { + group = { label: providerLabelOfRow(row.label), rows: [] } + groups.set(provider, group) + top.push({ id: providerGroupRowId(provider), label: group.label, section: "provider" }) + } + group.rows.push(row) + } + return { top, groups } +} + +/** Suffix the row matching `activeId` (if any) so it reads as the current pick. */ +function annotateCurrent( + rows: readonly ProductHostModelOption[], + activeId: string | undefined, +): ProductHostModelOption[] { + if (activeId === undefined) return [...rows] + return rows.map((r) => (r.id === activeId ? { ...r, label: `${r.label} (current)` } : r)) +} + export type ProductHostSend = ( text: string, attachments?: readonly PendingImageAttachment[], @@ -69,9 +135,19 @@ export type ProductHostDeliver = ( attachments?: readonly PendingImageAttachment[], ) => void +/** + * `section` groups rows for the provider-first picker: "recent" and + * "favorites" stay flat at the top (already single models, reachable without + * descending); "provider" rows are grouped into one top-level entry per + * provider (or per account, since each configured provider entry is already + * account-scoped — `codex/abk-labs`, `codex/dirtroad`); "unconnected" stays + * flat as a "connect →" row. Omitted (from a caller not using + * buildModelsFirstCatalog) falls back to one flat list, unwrapped. + */ export type ProductHostModelOption = { readonly id: string readonly label: string + readonly section?: "recent" | "favorites" | "provider" | "unconnected" } export type ProductHostConfig = { @@ -433,21 +509,47 @@ export async function mountProductHost( const onSelect = config.onModelSelect const onConnect = config.onConnectProvider const onFavoriteToggle = config.onFavoriteToggle - openModels = (): void => { + + // Provider rows have no catalog entry of their own to describe; fall back + // to a plain model count so the description zone is never blank. + const describe = (itemId: string): ItemDescription | null => { + const groupProvider = providerFromGroupRowId(itemId) + if (groupProvider !== null) { + const { groups } = groupModelsForPicker(currentModels) + const count = groups.get(groupProvider)?.rows.length ?? 0 + return { + what: `${count} model${count === 1 ? "" : "s"} available.`, + impact: "Press Enter to see them.", + tone: "plain", + } + } + return currentDescribeModel?.(itemId) ?? null + } + + const openLevel = (items: readonly ProductHostModelOption[], onCancel?: () => void): void => { openModelPickerOverlay(shell, { - items: currentModels.map((m) => m.label), - itemIds: currentModels.map((m) => m.id), + items: items.map((m) => m.label), + itemIds: items.map((m) => m.id), onAccept: (sel) => { - const id = sel.id ?? currentModels[sel.index]?.id + const id = sel.id ?? items[sel.index]?.id if (!id) return const providerName = id.startsWith("connect:") ? id.slice("connect:".length) : null if (providerName !== null) { onConnect?.(providerName) return } + const groupProvider = providerFromGroupRowId(id) + if (groupProvider !== null) { + const { groups } = groupModelsForPicker(currentModels) + const group = groups.get(groupProvider) + if (group !== undefined) { + openLevel(annotateCurrent(group.rows, activeModelId()), openModels) + } + return + } onSelect(id) }, - ...(currentDescribeModel !== undefined ? { describe: currentDescribeModel } : {}), + describe, ...(onFavoriteToggle !== undefined ? { onAction: (itemId, key) => { @@ -455,14 +557,38 @@ export async function mountProductHost( // bare letter narrows the list instead of toggling a favorite. const name = typeof key.name === "string" ? key.name.toLowerCase() : "" if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false - if (itemId.startsWith("connect:")) return false + if (itemId.startsWith("connect:") || providerFromGroupRowId(itemId) !== null) return false onFavoriteToggle(itemId) return true }, } : {}), + ...(onCancel !== undefined ? { onCancel } : {}), }) } + + // Recent's first row (if any) is the model just switched to — the closest + // thing to a live "current model" id without threading one through from + // the runner. Used only to mark that row "(current)" wherever it appears. + const activeModelId = (): string | undefined => + currentModels.find((r) => r.section === "recent")?.id + + openModels = (): void => { + const { top, groups } = groupModelsForPicker(currentModels) + const activeId = activeModelId() + // The active model's own row already reads "(current)" via annotateCurrent + // below; when it lives inside a provider group, mark the group row too + // so the pick is visible without descending into it. + const activeGroupId = [...groups.entries()].find(([, g]) => + g.rows.some((r) => r.id === activeId), + )?.[0] + const withGroupMark = activeGroupId === undefined + ? top + : top.map((r) => + r.id === providerGroupRowId(activeGroupId) ? { ...r, label: `${r.label} (current)` } : r, + ) + openLevel(annotateCurrent(withGroupMark, activeId)) + } ;(shell as AppShell & { __openModels?: () => void }).__openModels = openModels } diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index 55ddcf909..e9ccd5dcf 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -6,7 +6,7 @@ import type { KeyEvent } from "@opentui/core" import type { CostSummary } from "../cost/cost-summary.js" import type { SubAgentSession } from "../subagent/session-store.js" import { createHarness } from "./harness.js" -import { closeInsetOverlay, runOverlayAction } from "./shell.js" +import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js" import { mountRunnerHost, observeSessionFromSubAgents, @@ -219,7 +219,7 @@ describe("mountRunnerHost model picker", () => { host.refreshModels([{ provider: "xai", model: "grok-4" }], []) closeInsetOverlay(host.shell) expect(host.openSurface("models")).toBe(true) - expect(host.shell.overlayItems[0]).toBe("xai / grok-4") + expect(host.shell.overlayItems[0]).toBe("xai / grok-4 (current)") } finally { host.dispose() harness.destroy() @@ -245,6 +245,9 @@ describe("mountRunnerHost model picker", () => { }) try { expect(host.openSurface("models")).toBe(true) + // Single provider, single model: top level shows the "xai" provider + // group first — descend into it before the model row is focusable. + acceptOverlaySelection(host.shell) const fKey = { name: "f", ctrl: false, meta: false, option: true } as KeyEvent expect(runOverlayAction(host.shell, fKey)).toBe(true) expect(toggled).toEqual(["xai:grok-4"]) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index ae57d08fa..28413b624 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3527,10 +3527,15 @@ export function closeInsetOverlay(shell: AppShell): void { const prior = wasPalette ? bag?.priorOverlay ?? null : null // Permissions/operator overlays back a caller awaiting ev.resolve — Esc must // still settle that promise (as a deny/cancel) or the caller hangs forever. - // Palette/mentions/copy have no such awaited caller, so they drop silently. + // model_picker's onCancel is how the provider-first picker steps back to + // the provider level instead of closing outright; harmless no-op for a + // caller that never set one. Palette/mentions/copy have no awaited caller + // and no back-navigation, so they drop silently. const cancelable = !prior && - (shell.overlayKind === "permissions" || shell.overlayKind === "operator") + (shell.overlayKind === "permissions" || + shell.overlayKind === "operator" || + shell.overlayKind === "model_picker") const onCancel = cancelable ? bag?.overlayOnCancel ?? null : null shell.overlayList = null diff --git a/src/tui/command-registry-setup.test.ts b/src/tui/command-registry-setup.test.ts index b49d097fc..76138cb09 100644 --- a/src/tui/command-registry-setup.test.ts +++ b/src/tui/command-registry-setup.test.ts @@ -16,14 +16,6 @@ describe("session command registry setup", () => { expect(getCommand("goal")).toBeDefined(); }); - test("hides tier commands until a tier is configured", () => { - setUpCommandRegistry(undefined, []); - expect(listCommands().map((c) => c.name)).not.toContain("fast"); - - setUpCommandRegistry({ providers: {}, tiers: { fast: { provider: "p", model: "m" } } }, []); - expect(listCommands().map((c) => c.name)).toContain("fast"); - }); - test("applies hidden commands from settings", () => { setUpCommandRegistry({ providers: {}, hiddenCommands: ["help"] }, []); expect(listCommands().map((c) => c.name)).not.toContain("help"); diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 5c9623320..7ac1228de 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "bun:test"; -import { getCommand, listCommands } from "./registry.js"; +import { getCommand } from "./registry.js"; import type { CommandContext } from "./registry.js"; -import { registerBuiltInCommands, setConfiguredTiers } from "./built-in.js"; +import { registerBuiltInCommands } from "./built-in.js"; import { buildCostSummary } from "../../cost/cost-summary.js"; registerBuiltInCommands(); @@ -93,37 +93,11 @@ describe("/new command", () => { }); }); -describe("tier commands", () => { - it("registers a slash command for each provider tier", () => { - expect(getCommand("fast")).toBeDefined(); - expect(getCommand("standard")).toBeDefined(); - expect(getCommand("clever")).toBeDefined(); - }); - - it("each emits a tier-switch intent carrying its tier name", () => { - for (const tier of ["fast", "standard", "clever"] as const) { - expect(getCommand(tier)!.handler("", makeCtx())).toEqual({ type: "tier", tier }); - } - }); - - it("are hidden from the menu until configured, then appear", () => { - // Reset to nothing configured: no tier command surfaces in the menu. - setConfiguredTiers({}); - let names = listCommands().map((c) => c.name); - expect(names).not.toContain("fast"); - expect(names).not.toContain("standard"); - expect(names).not.toContain("clever"); - - // Still callable directly — visibility is display-only, never a hard gate. - expect(getCommand("fast")).toBeDefined(); - - setConfiguredTiers({ fast: { provider: "fp", model: "fp-large" } }); - names = listCommands().map((c) => c.name); - expect(names).toContain("fast"); - expect(names).not.toContain("standard"); - expect(names).not.toContain("clever"); - - setConfiguredTiers({}); +describe("removed tier commands", () => { + it("/fast, /standard, /clever are not registered", () => { + expect(getCommand("fast")).toBeUndefined(); + expect(getCommand("standard")).toBeUndefined(); + expect(getCommand("clever")).toBeUndefined(); }); }); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index f84102951..4bad7a493 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -1,5 +1,4 @@ import { registerCommand } from "./registry.js"; -import { PROVIDER_TIERS, type ProviderTier, type TierConfig } from "../../config/settings.js"; import { formatGoalStatus, type GoalSetOpts } from "../../agent/goal.js"; import { formatCostCommandOutput } from "../../cost/cost-summary.js"; import { @@ -8,19 +7,6 @@ import { resolveChangelogPath, } from "../../changelog/index.js"; -// Which tiers are currently assigned. Defaults to empty so /fast, /standard, -// /clever stay out of the slash menu until the user configures one; the runner -// syncs this whenever tier state changes. getCommand still resolves them -// regardless, so an in-flight reconfigure never strands a typed command. -const configuredTiers = new Set(); - -export function setConfiguredTiers(tiers: Partial>): void { - configuredTiers.clear(); - for (const tier of PROVIDER_TIERS) { - if (tiers[tier] !== undefined) configuredTiers.add(tier); - } -} - const GOAL_CLEAR_ALIASES = new Set(["clear", "stop", "off", "reset", "none", "cancel"]); /** @@ -304,16 +290,4 @@ export function registerBuiltInCommands(): void { }, }); - // One slash command per provider tier so a configured tier is one keystroke to - // switch to. The handler only emits the intent; the runner resolves the tier's - // current provider+model against live state and applies it, so a tier reassigned - // mid-session via /model takes effect immediately on the next / call. - for (const tier of PROVIDER_TIERS) { - registerCommand({ - name: tier, - description: `Switch the active model to the ${tier} tier`, - handler: () => ({ type: "tier", tier: tier as ProviderTier }), - available: () => configuredTiers.has(tier), - }); - } } diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index b97b073a9..451cdd57c 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -1,4 +1,3 @@ -import type { ProviderTier } from "../../config/settings.js"; import type { GoalSnapshot, GoalSetOpts, GoalResumeOpts } from "../../agent/goal.js"; import type { CostSummary } from "../../cost/cost-summary.js"; @@ -30,7 +29,6 @@ export type CommandResult = | { type: "modal"; modal: "agent" | "codex-login" | "xai-login" } | { type: "workflow"; name: string; args?: string } | { type: "paste-image" } - | { type: "tier"; tier: ProviderTier } | { type: "noop" }; export type SubcommandDefinition = { @@ -51,7 +49,7 @@ export type CommandDefinition = { handler: (args: string, ctx: CommandContext) => CommandResult; // Optional visibility gate. When present and returns false the command is // omitted from listCommands (the slash menu) but still callable via - // getCommand — so a tier command stays resolvable even mid-reconfigure. + // getCommand. available?: () => boolean; }; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 4fe5821c9..38b0bb26c 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -24,7 +24,6 @@ import { localSettingsPath, markTelemetryNoticeShown, pushRecentModel, - resolveTier, saveGlobalSettings, saveLocalSettings, shellTimeoutFromSettings, @@ -35,7 +34,6 @@ import { type Settings, type LocalSettings, type PluginConfig, - type ProviderTier, } from "../config/settings.js"; import { providerChoices } from "../tui-opentui/provider-setup.js"; import type { SessionModeScope } from "../tui-opentui/command-surfaces.js"; @@ -79,7 +77,7 @@ import { type CommandContext, type CommandResult, } from "./commands/registry.js"; -import { registerBuiltInCommands, setConfiguredTiers } from "./commands/built-in.js"; +import { registerBuiltInCommands } from "./commands/built-in.js"; import type { PluginModule } from "../plugins/loader.js"; import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; import { TELEMETRY_NOTICE } from "../telemetry/index.js"; @@ -111,7 +109,6 @@ import { createChatDirector } from "../agent/director.js"; import { createGoalGovernor } from "../agent/goal.js"; import { createGoalEvaluator } from "../agent/goal-evaluator.js"; import { loadGoalState, saveGoalState } from "../session/goal-state.js"; -import { buildInferenceSourceForRef, tierProviderRefs } from "../config/inference-sources.js"; import { loadAgentProfiles, type AgentProfile } from "../agent/profiles.js"; import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js"; import { createPermissionGate } from "../permission/gate.js"; @@ -375,7 +372,6 @@ export function setUpCommandRegistry( ): void { const pluginConfig = settings?.plugins ?? {}; registerBuiltInCommands(); - setConfiguredTiers(settings?.tiers ?? {}); registerWorkflowPlugins(plugins, pluginConfig); registerCommandPlugins(plugins, pluginConfig); setHiddenCommands(settings?.hiddenCommands ?? []); @@ -709,7 +705,7 @@ export async function runTUI(initialConfig: Config): Promise { .map((m) => toDescriptor(m)) .filter((d): d is PluginDescriptor => d !== undefined); // Attach agent profiles to their descriptors so the /plugins UI can show - // which sub-agents and tiers a plugin contributes. + // which sub-agents a plugin contributes. for (const mod of livePluginModules) { if (mod.manifest?.kind !== "agent" || mod.agentPlugin === undefined) continue; const desc = pluginDescriptors.find((d) => d.id === mod.manifest!.id); @@ -719,7 +715,6 @@ export async function runTUI(initialConfig: Config): Promise { .filter((a): a is Record => typeof a === "object" && a !== null && "id" in a) .map((a) => ({ id: String(a["id"]), - ...(typeof a["tier"] === "string" ? { tier: a["tier"] } : {}), ...(typeof a["description"] === "string" ? { description: a["description"] } : {}), })); } @@ -809,8 +804,7 @@ export async function runTUI(initialConfig: Config): Promise { await persistPluginSettings(); }, verify: async (id, credentials) => { - // Agent plugins verify by checking they contribute valid profiles and - // that each profile's tier resolves to a configured provider. + // Agent plugins verify by checking they contribute valid profiles. const agentMod = livePluginModules.find((m) => m.manifest?.id === id && m.manifest?.kind === "agent"); if (agentMod !== undefined) { const verifyDiag = createPluginLoadDiagnostics(); @@ -821,14 +815,7 @@ export async function runTUI(initialConfig: Config): Promise { ); emitPluginWarningSummary(verifyDiag); if (profiles.length === 0) return { ok: false, message: "No valid agent profiles found" }; - // Check tier resolution so the user knows if the provider is configured. - const unresolved = profiles.filter( - (p) => p.tier !== undefined && resolveTier(p.tier as ProviderTier, config.settings ?? { providers: {} }) === null, - ); - const tierHint = unresolved.length > 0 - ? ` (${unresolved.length} unresolved tier${unresolved.length === 1 ? "" : "s"} — set in /model → tiers)` - : ""; - return { ok: true, message: `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}${tierHint}` }; + return { ok: true, message: `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}` }; } // Tool plugins verify by loading (the factory must construct without // error and yield at least one tool). @@ -1228,31 +1215,10 @@ export async function runTUI(initialConfig: Config): Promise { } // Goal governor survives director rebuilds; reattached in the factory below. - // Evaluator prefers the fast tier when configured, else the live session model. - // Fail-open if inference fails. + // Evaluator runs on the live session model. const goalGovernor = createGoalGovernor({ evaluate: createGoalEvaluator({ - getSource: () => { - const settings = config.settings; - const refs = tierProviderRefs("fast", settings, { fallbackChain: true }); - const head = refs[0]; - - if (head !== undefined) { - const fast = buildInferenceSourceForRef( - head, - { - sessionId, - catalog: config.providers, - ...(config.reasoningEffort !== undefined - ? { reasoningEffort: config.reasoningEffort } - : {}), - }, - settings, - ); - if (fast !== null) return fast; - } - return liveSource; - }, + getSource: () => liveSource, deps: inferenceDeps, }), onChange: (snap) => { @@ -1861,9 +1827,6 @@ export async function runTUI(initialConfig: Config): Promise { case "workflow": systemRow(workflowController.start(result.name)); return; - case "tier": - systemRow(`Tier ${result.tier} selected`); - return; case "noop": return; case "overlay": diff --git a/tests/unit/data-only-agent.test.ts b/tests/unit/data-only-agent.test.ts index 73c947543..f7c0962e6 100644 --- a/tests/unit/data-only-agent.test.ts +++ b/tests/unit/data-only-agent.test.ts @@ -157,22 +157,22 @@ describe("loadDataOnlyAgentPlugin", () => { expect(agent.capabilities!.tools).toEqual(["run_shell"]); }); - test("tier alias is accepted", async () => { + test("bare tier frontmatter is ignored (tiers were removed)", async () => { const dir = await makePlugin({ "agents/a.md": "---\ntier: clever\n---\nbody\n", }); const plugin = await loadDataOnlyAgentPlugin(dir, { pluginId: "p" }); - const agent = plugin!.agentPlugin.agents[0] as { tier?: string }; - expect(agent.tier).toBe("clever"); + const agent = plugin!.agentPlugin.agents[0] as { inference?: unknown }; + expect(agent.inference).toBeUndefined(); }); - test("Claude Code effort:high maps to tier clever", async () => { + test("bare Claude Code effort:high is ignored without a model to attach it to", async () => { const dir = await makePlugin({ "agents/a.md": "---\neffort: high\n---\nbody\n", }); const plugin = await loadDataOnlyAgentPlugin(dir, { pluginId: "p" }); - const agent = plugin!.agentPlugin.agents[0] as { tier?: string }; - expect(agent.tier).toBe("clever"); + const agent = plugin!.agentPlugin.agents[0] as { inference?: unknown }; + expect(agent.inference).toBeUndefined(); }); test("native inference block (single leg) is accepted", async () => { diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index 06521db7f..c4974f793 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -1,15 +1,8 @@ import { test, expect } from "bun:test"; import { - appendTierEntry, buildInferenceSourceForRef, buildMainSessionSources, buildSubagentSources, - cycleTierMode, - formatTierChain, - moveTierLeg, - normalizeTierDefinition, - removeTierLeg, - tierProviderRefs, } from "../../src/config/inference-sources.js"; import type { Settings } from "../../src/config/settings.js"; @@ -40,35 +33,6 @@ const catalog: ProviderCatalogEntry[] = [ }, ]; -test("normalizeTierDefinition upgrades legacy assignment to pin chain", () => { - const def = normalizeTierDefinition({ provider: "openai", model: "gpt-4o" }); - expect(def).toEqual({ mode: "pin", order: [{ provider: "openai", model: "gpt-4o" }] }); -}); - -test("appendTierEntry prepends without duplicate refs", () => { - const next = appendTierEntry( - { mode: "prefer", order: [{ provider: "openai", model: "gpt-4o" }] }, - { provider: "local", model: "llama" }, - ); - expect(next.order.map((r) => r.provider)).toEqual(["local", "openai"]); - const again = appendTierEntry(next, { provider: "openai", model: "gpt-4o" }); - expect(again.order).toHaveLength(2); -}); - -test("prefer mode appends settings providers as fallback tail", () => { - const settings: Settings = { - providers: { - openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-4o"] }, - local: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama"] }, - }, - tiers: { - fast: { mode: "prefer", order: [{ provider: "openai", model: "gpt-4o-mini" }] }, - }, - }; - const refs = tierProviderRefs("fast", settings, { fallbackChain: true }); - expect(refs.map((r) => `${r.provider}/${r.model}`)).toEqual(["openai/gpt-4o-mini", "local/llama"]); -}); - test("buildInferenceSourceForRef uses bifrost provider when flag set", () => { const source = buildInferenceSourceForRef( { provider: "bifrost", model: "gpt-4o" }, @@ -79,7 +43,7 @@ test("buildInferenceSourceForRef uses bifrost provider when flag set", () => { expect(source?.baseURL).toBe("http://localhost:8080/v1"); }); -test("buildInferenceSourceForRef applies tier leg reasoning effort", () => { +test("buildInferenceSourceForRef applies leg reasoning effort", () => { const settings: Settings = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-5"] }, @@ -93,18 +57,12 @@ test("buildInferenceSourceForRef applies tier leg reasoning effort", () => { expect(source?.defaults?.providerOptions).toEqual({ reasoning_effort: "high" }); }); -test("buildMainSessionSources uses standard tier chain with active head", () => { +test("buildMainSessionSources backs the active head with other configured providers", () => { const settings: Settings = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-4o", "gpt-4o-mini"] }, local: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama"] }, }, - tiers: { - standard: { - mode: "prefer", - order: [{ provider: "openai", model: "gpt-4o-mini" }], - }, - }, }; const bundle = buildMainSessionSources({ settings, @@ -117,37 +75,21 @@ test("buildMainSessionSources uses standard tier chain with active head", () => expect(bundle.defaultSource).toBe("openai"); }); -test("buildSubagentSources uses full fallback chain for tier", () => { +test("buildSubagentSources backs the head with other configured providers", () => { const settings: Settings = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-4o"] }, local: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama"] }, }, - tiers: { - clever: { mode: "prefer", order: [{ provider: "openai", model: "gpt-4o" }] }, - }, }; const bundle = buildSubagentSources({ settings, catalog: [...catalog], - tier: "clever", head: { provider: "openai", model: "gpt-4o" }, sessionId: "sub", }); expect(bundle.sources.length).toBeGreaterThanOrEqual(2); -}); - -test("cycleTierMode toggles pin and prefer", () => { - expect(cycleTierMode({ mode: "pin", order: [] }).mode).toBe("prefer"); - expect(formatTierChain({ mode: "pin", order: [{ provider: "a", model: "m" }] })).toContain("pin"); -}); - -test("formatTierChain shows reasoning effort on legs", () => { - const label = formatTierChain({ - mode: "pin", - order: [{ provider: "openai", model: "gpt-5", reasoningEffort: "high" }], - }); - expect(label).toContain("gpt-5@high"); + expect(bundle.defaultSource).toBe("openai"); }); test("buildInferenceSourceForRef routes OpenCode Go models by protocol", () => { @@ -209,11 +151,3 @@ test("buildInferenceSourceForRef uses anthropic provider when flag set", () => { expect(source?.provider).toBe("anthropic"); expect(source?.baseURL).toBe("https://api.anthropic.com"); }); - -test("removeTierLeg and moveTierLeg edit the chain", () => { - const def = { mode: "prefer" as const, order: [{ provider: "a", model: "1" }, { provider: "b", model: "2" }] }; - const one = removeTierLeg(def, 0); - expect(one?.order.map((r) => r.provider)).toEqual(["b"]); - expect(removeTierLeg(one, 0)).toBeUndefined(); - expect(moveTierLeg(def, 1, -1)?.order.map((r) => r.provider)).toEqual(["b", "a"]); -}); \ No newline at end of file diff --git a/tests/unit/tier-failover-chain.test.ts b/tests/unit/tier-failover-chain.test.ts deleted file mode 100644 index 263eebdd4..000000000 --- a/tests/unit/tier-failover-chain.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { test, expect } from "bun:test"; -import { createSourceRegistry } from "@intx/agent"; -import { buildMainSessionSources } from "../../src/config/inference-sources.js"; -import type { ProviderCatalogEntry } from "../../src/config/index.js"; -import type { Settings } from "../../src/config/settings.js"; - -const catalog: ProviderCatalogEntry[] = [ - { - name: "primary", - baseURL: "https://primary.test/v1", - apiKey: "pk", - models: ["big"], - defaultModel: "big", - }, - { - name: "fallback", - baseURL: "https://fallback.test/v1", - apiKey: "fk", - models: ["small"], - defaultModel: "small", - }, -]; - -test("main session source list matches agent registry failover order", () => { - const settings: Settings = { - providers: { - primary: { baseURL: catalog[0]!.baseURL, apiKey: "pk", models: ["big"] }, - fallback: { baseURL: catalog[1]!.baseURL, apiKey: "fk", models: ["small"] }, - }, - tiers: { - standard: { - mode: "pin", - order: [{ provider: "fallback", model: "small" }], - }, - }, - }; - - const bundle = buildMainSessionSources({ - settings, - catalog, - activeProvider: "primary", - activeModel: "big", - sessionId: "sess", - }); - - expect(bundle.sources.map((s) => s.id)).toEqual(["primary", "fallback"]); - expect(bundle.defaultSource).toBe("primary"); - - const reg = createSourceRegistry({ - sources: bundle.sources, - defaultSource: bundle.defaultSource, - }); - expect(reg.active.id).toBe("primary"); - expect(reg.failOverToNextSource()).toBe(true); - expect(reg.active.id).toBe("fallback"); - expect(reg.failOverToNextSource()).toBe(false); -}); \ No newline at end of file