diff --git a/skills/subagents/SKILL.md b/skills/subagents/SKILL.md index 9268ba15e..22d78a3d7 100644 --- a/skills/subagents/SKILL.md +++ b/skills/subagents/SKILL.md @@ -26,6 +26,12 @@ Each line is one of: Pass the `name` as ``. Prefer a matching profile; otherwise use a provider. Keep the default model and effort unless the task needs an override (`--model`, `--effort`). +Never guess model names. List the exact `--model` values (and supported efforts) a provider accepts: + +```bash +devspace agents models +``` + ## Start work Give the subagent a self-contained brief with the objective, relevant paths, constraints, context it cannot infer, and the expected result. It receives this brief (and its profile instructions), not your conversation. diff --git a/src/cli.ts b/src/cli.ts index 9dd793afe..b55c93f9f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,7 +40,9 @@ import { formatAgentTurnEvent, formatAgentReceipt, formatAgentSummary, + formatAgentModels, formatAgentTargetCatalog, + type AgentModelsOutput, presentAgentObservation, presentAgentReceipt, presentAgentSummary, @@ -657,6 +659,10 @@ async function runAgentsCommand(args: string[]): Promise { case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); + return; + case "models": + await runAgentWorkflowCommand(json, () => runAgentsModels(commandArgs, json)); + return; case "daemon": await runAgentsDaemon(commandArgs, json); @@ -705,6 +711,48 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { else printAgentXml(formatAgentTargetCatalog(output)); } +/** Asks each enabled provider (or the named one, enabled or not) which models it offers here. */ +async function runAgentsModels(args: string[], json: boolean): Promise { + if (args.length > 1) throw new Error("Usage: devspace agents models [provider] [--json]"); + const [name] = args; + const config = loadAgentConfig(); + const { workspaceRoot } = resolveAgentCliWorkspaceContext(config); + // Loaded on demand: the adapters pull in every provider SDK, which other commands never need. + const { createLocalAgentDrivers } = await import("./local-agent-adapters.js"); + const { providerErrorDetail } = await import("./local-agent-events.js"); + const drivers = createLocalAgentDrivers({ subagents: config.subagents }); + + const selected = name + ? drivers.filter((driver) => driver.providerInstanceId === name) + : drivers.filter((driver) => config.subagents.providers.some((provider) => provider.id === driver.providerInstanceId && provider.enabled)); + + if (name && selected.length === 0) throw new Error(`Unknown subagent provider: ${name}.`); + + const detail = (error: Parameters[0]) => { + const text = providerErrorDetail(error); + + return text ? { detail: text } : undefined; + }; + + const results: AgentModelsOutput[] = await Promise.all(selected.map(async (driver) => { + const models = await driver.listModels!(workspaceRoot); + + return models.isOk() + ? { provider: driver.providerInstanceId, models: models.value } + : { + provider: driver.providerInstanceId, + error: { + code: models.error.code, + message: models.error.message, + ...optionalFields(detail(models.error)), + }, + }; + })); + + if (json) printJson(results); + else printAgentXml(formatAgentModels(results)); +} + async function runAgentsList(args: string[], json: boolean): Promise { if (args.length > 0) throw new Error("Usage: devspace agents ls [--json]"); const config = loadAgentConfig(); @@ -1136,6 +1184,7 @@ function printAgentsHelp(): void { " devspace agents stop [--json]", " devspace agents events [--turn ] [--follow] [--json]", " devspace agents targets [--json]", + " devspace agents models [provider] [--json]", " devspace agents skill [--install [--project]]", " devspace agents daemon [--json]", "", diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 784624a34..28bf5f8ea 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -31,6 +31,7 @@ import type { LocalAgentRunResult, LocalAgentRuntime, LocalAgentRuntimeContext, + LocalAgentModel, LocalAgentWriteMode, } from "./local-agent-runtime.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; @@ -324,6 +325,18 @@ export class AcpRuntime implements LocalAgentRuntime { } } + /** Opens a throwaway read-only session in `workspaceRoot` to read the models the agent offers there. */ + async listModels(workspaceRoot: string): Promise { + const response = await this.connection.agent.request("session/new", { + cwd: workspaceRoot, + mcpServers: [], + ...this.additionalDirectoryParams(), + ...this.permissionMeta({ prompt: "", workspaceRoot, writeMode: "read_only" }), + }); + + return acpModelsFromSession(response, this.flavor); + } + private async openSession(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks): Promise { if (input.providerSessionId) { if (this.liveSessions.has(input.providerSessionId)) { @@ -594,6 +607,31 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { this.commandResolver = options.commandResolver ?? resolveAcpCommand; } + async listModels(workspaceRoot: string) { + const runtime = await this.createRuntime({ + agentId: "models", + providerInstanceId: this.providerInstanceId, + provider: this.provider, + workspaceRoot, + writeMode: "read_only", + }); + + if (runtime.isErr()) return runtime; + + return captureAgentProviderResult({ + provider: this.provider, + operation: "list_models", + run: async () => { + try { + // SAFETY: createRuntime only ever returns an AcpRuntime. + return await unsafeNarrow(runtime.value).listModels(workspaceRoot); + } finally { + await runtime.value.close(); + } + }, + }); + } + async createRuntime(context: LocalAgentRuntimeContext) { return captureAgentProviderResult({ provider: this.provider, @@ -896,6 +934,77 @@ export function acpCommandArgs( ]; } +/** Reads selectable models from a `session/new` response: Grok model state, a `model` select, or ACP `models`. */ +export function acpModelsFromSession(response: WireInput, flavor: AcpFlavor): LocalAgentModel[] { + if (flavor === "grok") { + const state = readGrokSessionState(response); + + return (state?.availableModels ?? []).map((model) => ({ + id: model.id, + ...optionalFields(model.reasoningEfforts.length > 0 ? { efforts: [...model.reasoningEfforts] } : undefined), + ...optionalFields(model.id === state?.currentModelId ? { default: true } : undefined), + })); + } + + const record = asRecord(response); + const session = asRecord(record?.newSessionResponse) ?? record; + + const selects = (readArray(session, "configOptions") ?? []).map(asRecord) + .filter((option): option is JsonObject => option?.type === "select"); + + const modelSelect = selects.find((option) => option.category === "model"); + + const effortSelect = selects.find((option) => option.category === "thought_level"); + const efforts = effortSelect ? flattenAcpSelectValues(effortSelect) : []; + + if (modelSelect) { + const current = directString(modelSelect.currentValue); + + return flattenAcpSelectOptions(modelSelect).map(({ value, name }) => ({ + id: value, + ...optionalFields(name ? { name } : undefined), + ...optionalFields(efforts.length > 0 ? { efforts } : undefined), + ...optionalFields(value === current ? { default: true } : undefined), + })); + } + + const models = asRecord(session?.models); + const current = directString(models?.currentModelId); + + return (readArray(models, "availableModels") ?? []).flatMap((entry) => { + const model = asRecord(entry); + const id = directString(model?.modelId); + + if (!id) return []; + + const name = directString(model?.name); + + return [{ + id, + ...optionalFields(name ? { name } : undefined), + ...optionalFields(id === current ? { default: true } : undefined), + }]; + }); +} + +function flattenAcpSelectOptions(option: JsonObject): Array<{ value: string; name?: string }> { + return (readArray(option, "options") ?? []).flatMap((item) => { + const record = asRecord(item); + const value = directString(record?.value); + + if (value) return [{ value, ...optionalFields(directString(record?.name) ? { name: directString(record?.name)! } : undefined) }]; + + return (readArray(record, "options") ?? []).flatMap((nested) => { + const nestedRecord = asRecord(nested); + const nestedValue = directString(nestedRecord?.value); + + return nestedValue + ? [{ value: nestedValue, ...optionalFields(directString(nestedRecord?.name) ? { name: directString(nestedRecord?.name)! } : undefined) }] + : []; + }); + }); +} + export function resolveAcpModelConfigUpdate( session: WireInput, model: string, diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index 4024b27bc..e4e05b09e 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -18,6 +18,7 @@ import type { LocalAgentRunResult, LocalAgentRuntime, LocalAgentRuntimeContext, + LocalAgentModel, LocalAgentWriteMode, } from "./local-agent-runtime.js"; @@ -37,6 +38,7 @@ export interface ClaudeQueryLike extends AsyncIterable { setPermissionMode(mode: ClaudePermissionMode): Promise; applyFlagSettings(settings: JsonObject): Promise; setModel?(model?: string): Promise; + supportedModels?(): Promise; } export interface ClaudeQueryFactoryInput { @@ -300,6 +302,39 @@ export class ClaudeLocalAgentDriver implements LocalAgentDriver { }, }); } + + async listModels(workspaceRoot: string) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "list_models", + run: async () => { + const context: LocalAgentRuntimeContext = { + agentId: "models", + providerInstanceId: this.providerInstanceId, + provider: this.provider, + workspaceRoot, + writeMode: "read_only", + }; + + // Nothing is ever pushed: the query only starts Claude Code so it can answer the models request. + const inputQueue = new AsyncInputQueue(); + + const query = await this.factory({ + context, + options: claudeQueryOptions(context, { prompt: "", workspaceRoot, writeMode: "read_only" }, this.env), + prompt: inputQueue, + }); + + try { + if (!query.supportedModels) throw new Error("Claude Agent SDK does not report supported models."); + + return claudeModelsFromSdk(await query.supportedModels()); + } finally { + query.close(); + } + }, + }); + } } async function defaultClaudeQueryFactory({ @@ -488,3 +523,23 @@ function asRecord(value: WireInput): JsonObject | undefined { ? unsafeNarrow(value) : undefined; } + +/** Maps the Claude Agent SDK's `supportedModels()` rows; `value` is what `--model` accepts. */ +export function claudeModelsFromSdk(models: readonly WireInput[]): LocalAgentModel[] { + return models.flatMap((entry) => { + const record = asRecord(entry); + const id = record?.value; + + if (!isJsonString(id) || !id) return []; + + const levels = record?.supportedEffortLevels; + const efforts = Array.isArray(levels) ? levels.filter(isJsonString) : []; + const name = record?.displayName; + + return [{ + id, + ...optionalFields(isJsonString(name) && name ? { name } : undefined), + ...optionalFields(efforts.length > 0 ? { efforts } : undefined), + }]; + }); +} diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index b7b66e62d..854b44ac7 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -23,6 +23,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, LocalAgentWriteMode, + LocalAgentModel, } from "./local-agent-runtime.js"; export interface ResolvedCodexCommand { @@ -131,6 +132,19 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { this.rpc.notify("initialized"); } + async listModels(): Promise { + const pages: WireInput[] = []; + let cursor: string | undefined; + + do { + const page = await this.rpc.request("model/list", optionalFields(cursor ? { cursor } : undefined)); + pages.push(page); + cursor = readString(page, "nextCursor"); + } while (cursor && pages.length < 20); + + return codexModelsFromPages(pages); + } + async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { return captureAgentProviderResult({ provider: this.provider, @@ -341,6 +355,25 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { }); } + async listModels(_workspaceRoot: string) { + const runtime = await this.createRuntime({ agentId: "models", providerInstanceId: this.providerInstanceId, provider: this.provider, workspaceRoot: _workspaceRoot }); + + if (runtime.isErr()) return runtime; + + return captureAgentProviderResult({ + provider: this.provider, + operation: "list_models", + run: async () => { + try { + // SAFETY: createRuntime only ever returns a CodexAppServerRuntime. + return await unsafeNarrow(runtime.value).listModels(); + } finally { + await runtime.value.close(); + } + }, + }); + } + private resolveCommand(): ResolvedCodexCommand | undefined { if (!this.commandResolved) { this.resolvedCommand = this.commandResolver(this.env); @@ -728,6 +761,18 @@ function readString(value: WireInput, key: string): string | undefined { return isJsonString(result) ? result : undefined; } +function readArray(value: WireInput, key: string): WireInput[] | undefined { + const result = asRecord(value)?.[key]; + + return Array.isArray(result) ? result : undefined; +} + +function readBoolean(value: WireInput, key: string): boolean | undefined { + const result = asRecord(value)?.[key]; + + return result === true || result === false ? result : undefined; +} + function directString(value: WireInput): string | undefined { return isJsonString(value) && value.trim() ? value.trim() : undefined; } @@ -754,3 +799,23 @@ function appendTail(value: string, chunk: string, maxBytes: number): string { return bytes.subarray(bytes.length - maxBytes).toString("utf8"); } + +/** Maps `model/list` pages to selectable models, skipping ones hidden from Codex's own picker. */ +export function codexModelsFromPages(pages: readonly WireInput[]): LocalAgentModel[] { + return pages.flatMap((page) => readArray(page, "data") ?? []).flatMap((entry) => { + const id = readString(entry, "model") ?? readString(entry, "id"); + + if (!id || readBoolean(entry, "hidden") === true) return []; + + const efforts = (readArray(entry, "supportedReasoningEfforts") ?? []) + .map((effort) => readString(effort, "reasoningEffort")) + .filter((effort): effort is string => effort !== undefined); + + return [{ + id, + ...optionalFields(readString(entry, "displayName") ? { name: readString(entry, "displayName")! } : undefined), + ...optionalFields(efforts.length > 0 ? { efforts } : undefined), + ...optionalFields(readBoolean(entry, "isDefault") === true ? { default: true } : undefined), + }]; + }); +} diff --git a/src/local-agent-models.test.ts b/src/local-agent-models.test.ts new file mode 100644 index 000000000..18b6ae152 --- /dev/null +++ b/src/local-agent-models.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { acpModelsFromSession } from "./local-agent-acp.js"; +import { claudeModelsFromSdk } from "./local-agent-claude.js"; +import { codexModelsFromPages } from "./local-agent-codex.js"; +import { opencodeModelsFromCli } from "./local-agent-opencode.js"; +import { piModelsFromResponse } from "./local-agent-pi.js"; +import { formatAgentModels } from "./local-agent-presentation.js"; + +test("codex models come from every model/list page, without hidden models", () => { + assert.deepEqual(codexModelsFromPages([ + { + data: [ + { + model: "gpt-6.1-sol", + displayName: "GPT-6.1-Sol", + isDefault: true, + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: "low" }, { reasoningEffort: "high" }], + }, + { model: "internal", displayName: "Internal", hidden: true, supportedReasoningEfforts: [] }, + ], + nextCursor: "page-2", + }, + { data: [{ model: "gpt-6-luna", displayName: "GPT-6-Luna", hidden: false, supportedReasoningEfforts: [] }] }, + ]), [ + { id: "gpt-6.1-sol", name: "GPT-6.1-Sol", efforts: ["low", "high"], default: true }, + { id: "gpt-6-luna", name: "GPT-6-Luna" }, + ]); +}); + +test("claude models use the SDK value that --model accepts", () => { + assert.deepEqual(claudeModelsFromSdk([ + { value: "opus[1m]", displayName: "Opus", supportedEffortLevels: ["low", "max"] }, + { value: "haiku", displayName: "Haiku" }, + { displayName: "missing value" }, + ]), [ + { id: "opus[1m]", name: "Opus", efforts: ["low", "max"] }, + { id: "haiku", name: "Haiku" }, + ]); +}); + +test("cursor-style ACP models keep their exact option values, including grouped options", () => { + const session = { + sessionId: "s1", + configOptions: [ + { + type: "select", + category: "model", + currentValue: "composer-2.5[fast=true]", + options: [ + { value: "default[]", name: "Auto" }, + { name: "Composer", options: [{ value: "composer-2.5[fast=true]", name: "composer-2.5" }] }, + ], + }, + { type: "select", category: "thought_level", options: [{ value: "low" }, { value: "high" }] }, + ], + }; + + assert.deepEqual(acpModelsFromSession(session, "cursor"), [ + { id: "default[]", name: "Auto", efforts: ["low", "high"] }, + { id: "composer-2.5[fast=true]", name: "composer-2.5", efforts: ["low", "high"], default: true }, + ]); +}); + +test("grok ACP models carry their own reasoning efforts", () => { + const session = { + sessionId: "s1", + models: { + currentModelId: "grok-4.7", + availableModels: [ + { modelId: "grok-4.7", _meta: { reasoningEfforts: [{ id: "high" }, { id: "low" }] } }, + { modelId: "grok-4.5", _meta: { reasoningEfforts: [] } }, + ], + }, + }; + + const models = acpModelsFromSession(session, "grok"); + assert.equal(models[0]?.id, "grok-4.7"); + assert.equal(models[0]?.default, true); + assert.deepEqual(models[1], { id: "grok-4.5" }); +}); + +test("other ACP agents fall back to the standard models list", () => { + assert.deepEqual(acpModelsFromSession({ + sessionId: "s1", + models: { currentModelId: "b", availableModels: [{ modelId: "a", name: "A" }, { modelId: "b" }] }, + }, "copilot"), [{ id: "a", name: "A" }, { id: "b", default: true }]); +}); + +test("pi models are reported as provider/id", () => { + assert.deepEqual(piModelsFromResponse({ + models: [{ provider: "xai", id: "grok-4.5", name: "Grok 4.5" }, { provider: "xai" }], + }), [{ id: "xai/grok-4.5", name: "Grok 4.5" }]); +}); + +test("opencode models are the provider/model lines of `opencode models`", () => { + assert.deepEqual( + opencodeModelsFromCli("opencode/deepseek-v4.1-flash-free\nanthropic/claude-haiku-4-5\n\nWarning: something\n"), + [{ id: "opencode/deepseek-v4.1-flash-free" }, { id: "anthropic/claude-haiku-4-5" }], + ); +}); + +test("models output lists each provider, and a failing provider reports its cause", () => { + assert.equal(formatAgentModels([ + { provider: "codex", models: [{ id: "gpt-6-luna", efforts: ["low", "medium"], default: true }] }, + { provider: "pi", error: { code: "PROVIDER_EXECUTION_ERROR", message: "Pi failed.", detail: "exit 1" } }, + ]), [ + '', + '', + "", + 'Pi failed.exit 1', + ].join("\n")); +}); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 0965d9e4d..21b110478 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,3 +1,5 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { isObjectValue, isJsonString, optionalFields } from "./json-value.js"; import { unsafeNarrow } from "./unsafe-narrow.js"; import type { WireInput, JsonObject } from "./json-value.js"; @@ -18,6 +20,7 @@ import type { LocalAgentRunInput, LocalAgentRunResult, LocalAgentRuntime, + LocalAgentModel, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; import { startOpencodeServer, type OpencodeServerLike } from "./local-agent-opencode-server.js"; @@ -28,6 +31,7 @@ import { } from "./local-agent-opencode-v2.js"; import { probeOpenCodeBinary, + resolveOpenCodeCommand, requireOpenCodeV1NativeSessionId, type OpenCodeRuntimeProbeFunction, } from "./local-agent-opencode-version.js"; @@ -226,6 +230,33 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { }, }); } + + /** `opencode models` prints the same `provider/model` ids `--model` accepts, on both protocol generations. */ + async listModels(workspaceRoot: string) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "list_models", + run: async () => { + const { stdout } = await execFileAsync(resolveOpenCodeCommand(this.env), ["models"], { + cwd: workspaceRoot, + env: this.env, + timeout: 60_000, + maxBuffer: 4 * 1024 * 1024, + }); + + return opencodeModelsFromCli(stdout); + }, + }); + } +} + +const execFileAsync = promisify(execFile); + +export function opencodeModelsFromCli(stdout: string): LocalAgentModel[] { + return stdout.split("\n") + .map((line) => line.trim()) + .filter((line) => /^[^\s/]+\/\S+$/.test(line)) + .map((id) => ({ id })); } async function defaultOpencodeFactory( diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index cf076f51a..f0f60ea4a 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -23,6 +23,7 @@ import { } from "./local-agent-pi-rpc.js"; import type { LocalAgentDriver, + LocalAgentModel, LocalAgentRunCallbacks, LocalAgentRunInput, LocalAgentRunResult, @@ -325,6 +326,43 @@ export class PiLocalAgentDriver implements LocalAgentDriver { }, }); } + + async listModels(workspaceRoot: string) { + return captureAgentProviderResult({ + provider: this.provider, + operation: "list_models", + run: async () => { + const { connection, cleanup } = await this.factory({ + agentId: "models", + providerInstanceId: this.providerInstanceId, + provider: this.provider, + workspaceRoot, + writeMode: "read_only", + }, this.env); + + try { + return piModelsFromResponse(await connection.request({ type: "get_available_models" })); + } finally { + connection.close(); + await cleanup?.(); + } + }, + }); + } +} + +/** Reports Pi models as `provider/id`, which model selection always resolves unambiguously. */ +export function piModelsFromResponse(response: WireInput): LocalAgentModel[] { + return recordArray(response, "models").flatMap((entry) => { + const provider = piRecordString(entry, "provider"); + const id = piRecordString(entry, "id"); + + if (!provider || !id) return []; + + const name = piRecordString(entry, "name"); + + return [{ id: `${provider}/${id}`, ...optionalFields(name ? { name } : undefined) }]; + }); } async function defaultPiRpcFactory( diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index ccd78e6cf..09cd5232a 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -2,6 +2,7 @@ import { optionalFields } from "./json-value.js"; import type { LocalAgentCatalog } from "./local-agent-catalog.js"; import type { LocalAgentTurnEvent } from "./local-agent-events.js"; import type { LocalAgentRecord, LocalAgentStatus } from "./local-agent-store.js"; +import type { LocalAgentModel } from "./local-agent-runtime.js"; export type AgentCommandStatus = "running" | "completed" | "failed" | "stopped"; @@ -169,6 +170,30 @@ export function formatAgentTargetCatalog(catalog: AgentTargetCatalogOutput): str return [...targets, ...unavailable].join("\n"); } +export interface AgentModelsOutput { + provider: string; + models?: LocalAgentModel[]; + error?: { code: string; message: string; detail?: string }; +} + +export function formatAgentModels(results: readonly AgentModelsOutput[]): string { + return results.map(({ provider, models, error }) => { + if (error) { + const cause = error.detail ? `${escapeXmlText(error.detail)}` : ""; + + return `${escapeXmlText(error.message)}${cause}`; + } + + const rows = (models ?? []).map((model) => ``); + + return [``, ...rows, ""].join("\n"); + }).join("\n"); +} + export function formatAgentReceipt(receipt: AgentReceiptOutput): string { return ``; } diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts index c021e7fef..bf04e6c53 100644 --- a/src/local-agent-provider-registry.ts +++ b/src/local-agent-provider-registry.ts @@ -1,3 +1,5 @@ +import { Result } from "better-result"; +import { AgentProviderUnavailableError } from "./local-agent-errors.js"; import type { SubagentProviderConfig, SubagentsConfig, @@ -70,4 +72,16 @@ class ProviderInstanceDriver implements LocalAgentDriver { createRuntime(context: Parameters[0]) { return this.driver.createRuntime(context); } + + listModels(workspaceRoot: string) { + return this.driver.listModels + ? this.driver.listModels(workspaceRoot) + : Promise.resolve(Result.err(new AgentProviderUnavailableError({ + code: "PROVIDER_UNAVAILABLE", + provider: this.provider, + operation: "list_models", + retryable: false, + message: `${this.providerInstanceId} cannot list its models.`, + }))); + } } diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index afd76085f..86b1fc244 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -89,10 +89,20 @@ export interface LocalAgentRuntime { isAlive(): boolean; } +/** A model a harness reports as selectable. `id` is exactly what `--model` and profile `model` accept. */ +export interface LocalAgentModel { + id: string; + name?: string; + efforts?: string[]; + default?: boolean; +} + export interface LocalAgentDriver { readonly providerInstanceId: LocalAgentProviderInstanceId; readonly provider: LocalAgentDriverKind; readonly runtimePolicy: LocalAgentRuntimePolicy; readonly capabilities: LocalAgentCapabilities; createRuntime(context: LocalAgentRuntimeContext): Promise>; + /** Starts the harness just long enough to ask which models it offers in `workspaceRoot`. */ + listModels?(workspaceRoot: string): Promise>; } diff --git a/website/docs/orchestration/providers.mdx b/website/docs/orchestration/providers.mdx index 52a0e2eaf..a56b221df 100644 --- a/website/docs/orchestration/providers.mdx +++ b/website/docs/orchestration/providers.mdx @@ -86,6 +86,27 @@ Provider configuration takes precedence over equivalent process-level settings: | `GROK_COMMAND` | Grok executable. | | `GROK_AGENT_PROFILE` | Grok agent profile selected at startup. | +## List models + +Each harness names its models differently: Cursor's IDs carry settings such as `composer-2.5[fast=true]`, Pi +needs `provider/id`, and OpenCode uses `provider/model`. Ask the harness instead of guessing: + +```bash +devspace agents models # every enabled provider +devspace agents models cursor # one provider, enabled or not +``` + +```xml + + + + +``` + +Each `id` is exactly what `--model` and profile `model` accept. DevSpace starts the harness only long enough to ask +(Codex `model/list`, the Claude Agent SDK, `opencode models`, Pi RPC, or an ACP session), so no prompt is sent and +nothing is billed. A provider that cannot answer prints its error, with the cause, instead of a list. + ## Any ACP agent Any agent that speaks ACP over stdio can use `driver: "acp"` with a `command` diff --git a/website/docs/reference/cli.mdx b/website/docs/reference/cli.mdx index 396771c49..5c8122609 100644 --- a/website/docs/reference/cli.mdx +++ b/website/docs/reference/cli.mdx @@ -28,6 +28,7 @@ replaces detection. Availability does not check sign-in. | Command | Description | | --- | --- | | `devspace agents targets` | List usable providers and profiles, unavailable reasons, or `…` when disabled. | +| `devspace agents models [provider]` | Ask each enabled provider, or the named one, which models it offers here. Each `id` is exactly what `--model` and profile `model` accept; `efforts` and `default` appear when the harness reports them. A provider that cannot answer prints its error instead. | | `devspace agents run [options] ` | Start a new logical agent. | | `devspace agents continue [options] ` | Send a follow-up to an existing agent. | | `devspace agents show ` | Immediate snapshot. |