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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions skills/subagents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Each line is one of:

Pass the `name` as `<profile-or-provider>`. 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 <provider>
```

## 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.
Expand Down
49 changes: 49 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import {
formatAgentTurnEvent,
formatAgentReceipt,
formatAgentSummary,
formatAgentModels,
formatAgentTargetCatalog,
type AgentModelsOutput,
presentAgentObservation,
presentAgentReceipt,
presentAgentSummary,
Expand Down Expand Up @@ -657,6 +659,10 @@ async function runAgentsCommand(args: string[]): Promise<void> {
case "targets":
await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json));

return;
case "models":
await runAgentWorkflowCommand(json, () => runAgentsModels(commandArgs, json));

return;
case "daemon":
await runAgentsDaemon(commandArgs, json);
Expand Down Expand Up @@ -705,6 +711,48 @@ async function runAgentsTargets(args: string[], json: boolean): Promise<void> {
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<void> {
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}.`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const detail = (error: Parameters<typeof providerErrorDetail>[0]) => {
const text = providerErrorDetail(error);

return text ? { detail: text } : undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};

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<void> {
if (args.length > 0) throw new Error("Usage: devspace agents ls [--json]");
const config = loadAgentConfig();
Expand Down Expand Up @@ -1136,6 +1184,7 @@ function printAgentsHelp(): void {
" devspace agents stop <id> [--json]",
" devspace agents events <id> [--turn <turn-id>] [--follow] [--json]",
" devspace agents targets [--json]",
" devspace agents models [provider] [--json]",
" devspace agents skill [--install <claude-code|codex|cursor|opencode|agents> [--project]]",
" devspace agents daemon <status|stop|logs> [--json]",
"",
Expand Down
109 changes: 109 additions & 0 deletions src/local-agent-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
LocalAgentRunResult,
LocalAgentRuntime,
LocalAgentRuntimeContext,
LocalAgentModel,
LocalAgentWriteMode,
} from "./local-agent-runtime.js";
import { resolveExecutableCommand } from "./local-agent-command.js";
Expand Down Expand Up @@ -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<LocalAgentModel[]> {
const response = await this.connection.agent.request("session/new", {
cwd: workspaceRoot,
mcpServers: [],
...this.additionalDirectoryParams(),
...this.permissionMeta({ prompt: "", workspaceRoot, writeMode: "read_only" }),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

return acpModelsFromSession(response, this.flavor);
}

private async openSession(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks): Promise<string> {
if (input.providerSessionId) {
if (this.liveSessions.has(input.providerSessionId)) {
Expand Down Expand Up @@ -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<AcpRuntime>(runtime.value).listModels(workspaceRoot);
} finally {
await runtime.value.close();
}
},
});
}

async createRuntime(context: LocalAgentRuntimeContext) {
return captureAgentProviderResult({
provider: this.provider,
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions src/local-agent-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
LocalAgentRunResult,
LocalAgentRuntime,
LocalAgentRuntimeContext,
LocalAgentModel,
LocalAgentWriteMode,
} from "./local-agent-runtime.js";

Expand All @@ -37,6 +38,7 @@ export interface ClaudeQueryLike extends AsyncIterable<unknown> {
setPermissionMode(mode: ClaudePermissionMode): Promise<void>;
applyFlagSettings(settings: JsonObject): Promise<void>;
setModel?(model?: string): Promise<void>;
supportedModels?(): Promise<WireInput[]>;
}

export interface ClaudeQueryFactoryInput {
Expand Down Expand Up @@ -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<ClaudeUserMessage>();

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({
Expand Down Expand Up @@ -488,3 +523,23 @@ function asRecord(value: WireInput): JsonObject | undefined {
? unsafeNarrow<JsonObject>(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),
}];
});
}
Loading
Loading