diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 94186ce18..2c12319be 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -64,7 +64,7 @@ model can choose the right profile. ### `provider` -Required built-in provider id: +Required provider instance id. The default instance ids are: ```yaml provider: codex @@ -76,8 +76,9 @@ provider: copilot provider: grok ``` -Unsupported or custom providers are rejected. DevSpace maps providers to their -native integration: +Profiles may also reference named provider instances configured under +`subagents.providers`, such as `provider: codex-work`. Provider instances map +to one of these built-in driver integrations: - `codex`: the host-installed `codex app-server` command - `claude`: Claude Code SDK diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..d7b3675f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,6 +149,26 @@ Subagent providers are explicit. Omitted providers are disabled: } ``` +`id` is the provider instance id used by profiles and `devspace agents run`. +For the default built-in instance ids, `driver` is optional and defaults to the +same value. Named instances declare the driver explicitly, which allows +multiple independently configured instances of one integration: + +```jsonc +{ + "id": "codex-work", + "driver": "codex", + "enabled": true, + "env": { + "CODEX_HOME": "/home/alice/.codex-work", + }, +} +``` + +Profiles can then select that instance with `provider: codex-work`. The driver +is the implementation (`codex`, `claude`, `opencode`, `pi`, `cursor`, +`copilot`, or `grok`); provider-native session ids remain internal to DevSpace. + `subagents.instructions` controls when ChatGPT receives the managed workflow: | Value | Behavior | diff --git a/package.json b/package.json index fa39fb46c..57d36c7be 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "jsonc-parser": "^3.3.1", - "koffi": "^3.1.2", + "koffi": "3.2.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca7f368..611ce519d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^3.3.1 version: 3.3.1 koffi: - specifier: ^3.1.2 + specifier: 3.2.1 version: 3.2.1 lucide: specifier: ^1.24.0 diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index cf9ce8f84..de7a66041 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -177,92 +177,56 @@ "providers": { "type": "array", "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "codex", - "claude", - "cursor", - "copilot", - "grok" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } - }, - "command": { - "type": "string", - "minLength": 1, - "pattern": "\\S" - } - }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "driver": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" }, - { + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + }, + "env": { "type": "object", - "properties": { - "id": { - "type": "string", - "enum": [ - "opencode", - "pi" - ] - }, - "enabled": { - "type": "boolean" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "effort": { - "type": "string", - "minLength": 1 - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - }, - "additionalProperties": { - "type": "string" - } - } + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, - "required": [ - "id", - "enabled" - ], - "additionalProperties": false + "additionalProperties": { + "type": "string" + } + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "\\S" } - ] + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false } } }, diff --git a/src/cli.test.ts b/src/cli.test.ts index 3a622eb19..1cc6ba5e6 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -68,7 +68,8 @@ try { workspaceId: "ws_current", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", }).id, @@ -79,7 +80,8 @@ try { workspaceId: "ws_other", workspaceRoot: projectRoot, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }).id, { status: "running" }, ); diff --git a/src/cli.ts b/src/cli.ts index 3037f65e8..e02d4f5b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { formatLocalAgentProviderStatusSummary, } from "./local-agent-catalog.js"; import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { resolveSubagentsConfig } from "./local-agent-config.js"; import { parseLocalAgentContinueArgs, parseLocalAgentRunArgs, @@ -218,7 +218,7 @@ async function runInit({ force }: { force: boolean }): Promise { })); } - const currentSubagents = files.config.subagents; + const currentSubagents = resolveSubagentsConfig(files.config.subagents); const availability = getLocalAgentProviderAvailabilitySnapshot( process.env, currentSubagents, @@ -247,7 +247,7 @@ async function runInit({ force }: { force: boolean }): Promise { required: true, }); if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); - const selectedProviders = providerAnswer as LocalAgentProvider[]; + const selectedProviders = providerAnswer as string[]; const subagents = updateOnboardingSubagentsConfig( currentSubagents, selectedProviders, diff --git a/src/config-migration.ts b/src/config-migration.ts index 837d46f32..7614edf35 100644 --- a/src/config-migration.ts +++ b/src/config-migration.ts @@ -5,7 +5,7 @@ import { type DevspaceConfig, } from "./config-schema.js"; import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; +import { LOCAL_AGENT_DRIVER_KINDS } from "./local-agent-provider.js"; const legacyConfigSchema = z.object({ host: z.string().optional(), @@ -92,7 +92,7 @@ function migrateLegacySubagents( return { enabled: value, providers: value - ? LOCAL_AGENT_PROVIDERS.map((id) => ({ id, enabled: true })) + ? LOCAL_AGENT_DRIVER_KINDS.map((id) => ({ id, enabled: true })) : [], }; } diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..b9cd6c7fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { expandHomePath } from "./roots.js"; import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; -import type { SubagentsConfig } from "./local-agent-config.js"; +import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; export type { ToolMode } from "./config-schema.js"; @@ -75,7 +75,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), - subagents: stored.subagents, + subagents: resolveSubagentsConfig(stored.subagents), agentDir: normalizePath(stored.skills.agentDir), logging: { ...stored.logging, diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 8bb1f7e4f..9ce071df4 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,11 @@ const migrations: Migration[] = [ name: "local-agent-turns", up: migrateLocalAgentTurns, }, + { + version: 9, + name: "local-agent-provider-instances", + up: migrateLocalAgentProviderInstances, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -289,6 +294,22 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { `); } +function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { + const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ + name: string; + }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("provider") && !names.has("provider_instance_id")) { + sqlite.exec("alter table local_agent_sessions rename column provider to provider_instance_id"); + } + addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text"); + sqlite.exec(` + update local_agent_sessions + set driver = provider_instance_id + where driver is null or driver = '' + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 77b234368..a80f9c71f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -98,7 +98,8 @@ export const localAgentSessions = sqliteTable( workspaceId: text("workspace_id"), workspaceRoot: text("workspace_root").notNull(), profileName: text("profile_name").notNull(), - provider: text("provider").notNull(), + providerInstanceId: text("provider_instance_id").notNull(), + driver: text("driver").notNull(), model: text("model"), effort: text("effort"), providerSessionId: text("provider_session_id"), diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7a5222261..bb9998d15 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -256,6 +256,7 @@ await overlapRuntime.close(); const cachedContext = { agentId: "agt_acp", + providerInstanceId: "cursor", provider: "cursor" as const, workspaceRoot: "/tmp/project", writeMode: "allowed" as const, @@ -266,11 +267,13 @@ assert.deepEqual(acpCommandArgs("cursor", cachedContext), [ ]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, + providerInstanceId: "grok", provider: "grok", effort: "low", }), ["agent", "--reasoning-effort", "low", "stdio"]); assert.deepEqual(acpCommandArgs("grok", { ...cachedContext, + providerInstanceId: "grok", provider: "grok", effort: "low", }, { GROK_AGENT_PROFILE: " /tmp/grok-coding-only.md " }), [ @@ -314,7 +317,12 @@ if (process.platform === "win32") { ); await writeFile(command, `@ECHO OFF\r\n"${process.execPath}" "${recorder}" %*\r\n`); const shimDriver = new AcpLocalAgentDriver("copilot", process.env, () => command); - const shimStartup = await shimDriver.createRuntime({ ...cachedContext, provider: "copilot", workspaceRoot }); + const shimStartup = await shimDriver.createRuntime({ + ...cachedContext, + providerInstanceId: "copilot", + provider: "copilot", + workspaceRoot, + }); assert.equal(shimStartup.isErr(), true); if (shimStartup.isErr()) assert.equal(shimStartup.error.code, "PROVIDER_PROTOCOL_ERROR"); const forwarded = JSON.parse(await readFile(marker, "utf8")) as string[]; diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 078391ea9..bbbabb260 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -410,6 +410,7 @@ export class AcpRuntime implements LocalAgentRuntime { export class AcpLocalAgentDriver implements LocalAgentDriver { readonly provider: AcpProvider; + readonly providerInstanceId: string; // Keep ACP warm briefly, then let the generic pool close the process so the // daemon can reach its own idle shutdown state. readonly idleTimeoutMs = 5 * 60_000; @@ -422,6 +423,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { private readonly commandResolver: AcpCommandResolver = resolveAcpCommand, ) { this.provider = provider; + this.providerInstanceId = provider; } runtimeKey(context: LocalAgentRuntimeContext): string { diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 395072a1d..cbaf2e190 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -2,12 +2,14 @@ import assert from "node:assert/strict"; import { delimiter } from "node:path"; import { claudeCommandEnvironment, + createLocalAgentDrivers, extractOpenCodeFinalResponse, extractPiFinalResponse, extractPiProviderError, resolveAcpModelConfigUpdate, resolveAcpEffortConfigUpdate, } from "./local-agent-adapters.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; assert.deepEqual( resolveAcpModelConfigUpdate({ @@ -330,5 +332,31 @@ assert.equal( removeDevspaceNodeModulesBinFromPath([devspaceBin, userBin].join(delimiter)), userBin, ); +} +{ + const subagents = parseSubagentsConfig({ + enabled: true, + providers: [ + { id: "claude-work", driver: "claude", enabled: true }, + { id: "claude-personal", driver: "claude", enabled: true }, + ], + }); + const drivers = createLocalAgentDrivers({ subagents }); + assert.deepEqual( + drivers.map((driver) => [driver.providerInstanceId, driver.provider]), + [["claude-work", "claude"], ["claude-personal", "claude"]], + ); + const context = { + agentId: "agt_test", + providerInstanceId: "claude-work", + provider: "claude" as const, + workspaceRoot: "/tmp/project", + writeMode: "allowed" as const, + }; + assert.notEqual( + drivers[0]?.runtimeKey(context), + drivers[1]?.runtimeKey({ ...context, providerInstanceId: "claude-personal" }), + "provider instances sharing a driver must never share one runtime pool key", + ); } diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index d83d2fc8e..86076fbb0 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,9 +1,14 @@ import { localAgentProviderEnvironment, localAgentProviderEnvironmentOverrides, + type SubagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import { + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; import { AcpLocalAgentDriver, resolveAcpCommand, @@ -43,21 +48,63 @@ export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { const env = options.env ?? process.env; - const providerEnv = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironment(options.subagents, provider, env) - : env; - const providerEnvOverrides = (provider: LocalAgentProvider) => options.subagents - ? localAgentProviderEnvironmentOverrides(options.subagents, provider) + const instances = options.subagents?.providers ?? LOCAL_AGENT_DRIVER_KINDS.map((driver) => ({ + id: driver, + driver, + enabled: true, + } satisfies SubagentProviderConfig)); + return instances.map((instance) => new ProviderInstanceDriver( + instance.id, + createDriver(instance, options, env), + )); +} + +function createDriver( + instance: SubagentProviderConfig, + options: LocalAgentDriverOptions, + inheritedEnv: NodeJS.ProcessEnv, +): LocalAgentDriver { + const env = options.subagents + ? localAgentProviderEnvironment(options.subagents, instance.id, inheritedEnv) + : inheritedEnv; + const envOverrides = options.subagents + ? localAgentProviderEnvironmentOverrides(options.subagents, instance.id) : {}; - return [ - new CodexLocalAgentDriver(providerEnv("codex")), - new ClaudeLocalAgentDriver(options.claudeQueryFactory, providerEnv("claude")), - new OpencodeLocalAgentDriver(options.opencodeFactory, providerEnv("opencode")), - new PiLocalAgentDriver(options.piSessionFactory, providerEnvOverrides("pi")), - new AcpLocalAgentDriver("cursor", providerEnv("cursor")), - new AcpLocalAgentDriver("copilot", providerEnv("copilot")), - new AcpLocalAgentDriver("grok", providerEnv("grok")), - ]; + switch (instance.driver) { + case "codex": + return new CodexLocalAgentDriver(env); + case "claude": + return new ClaudeLocalAgentDriver(options.claudeQueryFactory, env); + case "opencode": + return new OpencodeLocalAgentDriver(options.opencodeFactory, env); + case "pi": + return new PiLocalAgentDriver(options.piSessionFactory, envOverrides); + case "cursor": + case "copilot": + case "grok": + return new AcpLocalAgentDriver(instance.driver, env); + } +} + +class ProviderInstanceDriver implements LocalAgentDriver { + readonly provider: LocalAgentDriverKind; + readonly idleTimeoutMs?: number; + + constructor( + readonly providerInstanceId: LocalAgentProviderInstanceId, + private readonly driver: LocalAgentDriver, + ) { + this.provider = driver.provider; + this.idleTimeoutMs = driver.idleTimeoutMs; + } + + runtimeKey(context: Parameters[0]): string { + return JSON.stringify([this.providerInstanceId, this.driver.runtimeKey(context)]); + } + + createRuntime(context: Parameters[0]) { + return this.driver.createRuntime(context); + } } export function extractLocalAgentResponseText(value: unknown): string { diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index a5592b42a..6088a0a7e 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -43,6 +43,7 @@ assert.equal( instructions: "on-demand", providers: [{ id: "codex", + driver: "codex", enabled: true, command: executable, env: { OPENAI_API_KEY: "configured-secret", EMPTY_VALUE: "" }, diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 14764488c..e6916c007 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,15 +1,18 @@ -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; import { resolveExecutableCommand } from "./local-agent-command.js"; import { localAgentProviderEnvironment, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; +import { + isLocalAgentDriverKind, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderAvailability { - name: LocalAgentProvider; + name: LocalAgentProviderInstanceId; available: boolean; reason?: string; note?: string; @@ -19,67 +22,83 @@ export function getLocalAgentProviderAvailabilitySnapshot( env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability[] { - return LOCAL_AGENT_PROVIDERS.map((provider) => ( - checkLocalAgentProviderAvailability(provider, env, config) + const configured = new Map(config?.providers.map((provider) => [provider.id, provider]) ?? []); + const instances = [ + ...LOCAL_AGENT_DRIVER_KINDS.map((driver) => configured.get(driver) ?? { id: driver, driver }), + ...(config?.providers.filter((provider) => !isLocalAgentDriverKind(provider.id)) ?? []), + ]; + return instances.map((instance) => checkLocalAgentProviderAvailability( + instance.id, + instance.driver, + env, + config, )); } function checkLocalAgentProviderAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, + driver: LocalAgentDriverKind, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): LocalAgentProviderAvailability { - const providerEnv = config ? localAgentProviderEnvironment(config, provider, env) : env; - switch (provider) { + const providerEnv = config ? localAgentProviderEnvironment(config, providerInstanceId, env) : env; + switch (driver) { case "codex": - return codexAvailability(providerEnv); + return codexAvailability(providerInstanceId, providerEnv); case "claude": return providerEnv.CLAUDE_COMMAND - ? commandAvailability(provider, providerEnv.CLAUDE_COMMAND, providerEnv) - : packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); + ? commandAvailability(providerInstanceId, providerEnv.CLAUDE_COMMAND, providerEnv) + : packageAvailability(providerInstanceId, "@anthropic-ai/claude-agent-sdk"); case "opencode": - return packageAvailability(provider, "@opencode-ai/sdk/v2"); + return packageAvailability(providerInstanceId, "@opencode-ai/sdk/v2"); case "pi": - return packageAvailability(provider, "@earendil-works/pi-coding-agent"); + return packageAvailability(providerInstanceId, "@earendil-works/pi-coding-agent"); case "cursor": - return commandAvailability(provider, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); + return commandAvailability(providerInstanceId, providerEnv.CURSOR_COMMAND ?? "cursor-agent", providerEnv); case "copilot": - return commandAvailability(provider, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); + return commandAvailability(providerInstanceId, providerEnv.COPILOT_COMMAND ?? "copilot", providerEnv); case "grok": - return commandAvailability(provider, providerEnv.GROK_COMMAND ?? "grok", providerEnv); + return commandAvailability(providerInstanceId, providerEnv.GROK_COMMAND ?? "grok", providerEnv); } } export function assertLocalAgentProviderAvailable( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, env: NodeJS.ProcessEnv = process.env, config?: SubagentsConfig, ): void { - const availability = checkLocalAgentProviderAvailability(provider, env, config); + const provider = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const driver = provider?.driver + ?? (isLocalAgentDriverKind(providerInstanceId) ? providerInstanceId : undefined); + if (!driver) throw new Error(`${providerInstanceId} provider is not configured.`); + const availability = checkLocalAgentProviderAvailability(providerInstanceId, driver, env, config); if (availability.available) return; throw new Error( - `${provider} provider is not available: ${availability.reason ?? "provider preflight failed"}`, + `${providerInstanceId} provider is not available: ${availability.reason ?? "provider preflight failed"}`, ); } function packageAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, packageName: string, ): LocalAgentProviderAvailability { try { import.meta.resolve(packageName); - return { name: provider, available: true }; + return { name: providerInstanceId, available: true }; } catch { return { - name: provider, + name: providerInstanceId, available: false, reason: `${packageName} package not found`, }; } } -function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailability { - const availability = commandAvailability("codex", env.CODEX_COMMAND ?? "codex", env); +function codexAvailability( + providerInstanceId: LocalAgentProviderInstanceId, + env: NodeJS.ProcessEnv, +): LocalAgentProviderAvailability { + const availability = commandAvailability(providerInstanceId, env.CODEX_COMMAND ?? "codex", env); return availability.available ? { ...availability, @@ -89,13 +108,13 @@ function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailabili } function commandAvailability( - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, command: string, env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { - if (resolveExecutableCommand(command, env)) return { name: provider, available: true }; + if (resolveExecutableCommand(command, env)) return { name: providerInstanceId, available: true }; return { - name: provider, + name: providerInstanceId, available: false, reason: `${command} executable not found`, }; diff --git a/src/local-agent-catalog.test.ts b/src/local-agent-catalog.test.ts index cc156251d..42acea196 100644 --- a/src/local-agent-catalog.test.ts +++ b/src/local-agent-catalog.test.ts @@ -10,9 +10,9 @@ const config: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true, model: "sonnet" }, - { id: "pi", enabled: false }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "pi", driver: "pi", enabled: false }, ], }; const statuses = buildLocalAgentProviderStatuses(config, [ @@ -23,7 +23,7 @@ const statuses = buildLocalAgentProviderStatuses(config, [ assert.equal(statuses.find((provider) => provider.id === "codex")?.usable, true); assert.equal(statuses.find((provider) => provider.id === "claude")?.usable, false); assert.equal(statuses.find((provider) => provider.id === "pi")?.usable, false); -assert.equal(statuses.find((provider) => provider.id === "opencode")?.enabled, false); +assert.equal(statuses.find((provider) => provider.id === "opencode"), undefined); const profiles: LocalAgentProfile[] = [ { diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts index c19904ba2..b446adab0 100644 --- a/src/local-agent-catalog.ts +++ b/src/local-agent-catalog.ts @@ -1,14 +1,14 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; -import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProfileSummary, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile, LocalAgentProfileSummary } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export interface LocalAgentProviderStatus { - id: LocalAgentProvider; + id: LocalAgentProviderInstanceId; + driver: LocalAgentDriverKind; enabled: boolean; available: boolean; usable: boolean; @@ -28,13 +28,14 @@ export function buildLocalAgentProviderStatuses( config: SubagentsConfig, availability: readonly LocalAgentProviderAvailability[], ): LocalAgentProviderStatus[] { - return LOCAL_AGENT_PROVIDERS.map((id) => { - const configured = config.providers.find((entry) => entry.id === id); + return config.providers.map((configured) => { + const id = configured.id; const live = availability.find((entry) => entry.name === id); const enabled = configured?.enabled === true; const available = live?.available === true; return { id, + driver: configured.driver, enabled, available, usable: config.enabled && enabled && available, diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index 14e364005..e6c0a2a0e 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -6,7 +6,7 @@ import { type ClaudeUserMessage, } from "./local-agent-claude.js"; import { createLocalAgentDrivers } from "./local-agent-adapters.js"; -import { subagentsConfigSchema } from "./local-agent-config.js"; +import { parseSubagentsConfig } from "./local-agent-config.js"; import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { @@ -56,6 +56,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { const context: LocalAgentRuntimeContext = { agentId: "agt_claude", + providerInstanceId: "claude", provider: "claude", workspaceRoot: "/tmp/project", model: "sonnet", @@ -241,7 +242,7 @@ const configuredDriver = createLocalAgentDrivers({ ANTHROPIC_API_KEY: "inherited", INHERITED: "yes", }, - subagents: subagentsConfigSchema.parse({ + subagents: parseSubagentsConfig({ enabled: true, providers: [{ id: "claude", diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index a639f2f46..7c8b0d9da 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -5,7 +5,7 @@ import { captureAgentProviderResult, isProgrammerDefect, } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -77,7 +77,7 @@ class AsyncInputQueue implements AsyncIterable { } export class ClaudeQueryRuntime implements LocalAgentRuntime { - readonly provider: LocalAgentProvider = "claude"; + readonly provider: LocalAgentDriverKind = "claude"; private readonly iterator: AsyncIterator; private alive = true; private closed = false; @@ -213,6 +213,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { export class ClaudeLocalAgentDriver implements LocalAgentDriver { readonly provider = "claude" as const; + readonly providerInstanceId = "claude"; readonly idleTimeoutMs = 3 * 60_000; constructor( diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index 1dcebc043..74383e882 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -12,7 +12,12 @@ import { } from "./local-agent-codex.js"; import { toAgentErrorPayload } from "./local-agent-errors.js"; -const cachedContext = { agentId: "agt_test", provider: "codex" as const, workspaceRoot: "/tmp/project" }; +const cachedContext = { + agentId: "agt_test", + providerInstanceId: "codex", + provider: "codex" as const, + workspaceRoot: "/tmp/project", +}; assert.equal(parseCodexVersion("codex-cli 0.9.1"), "0.9.1"); assert.equal(sandboxFor("read_only"), "read-only"); diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 937e44481..46c4c007b 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -230,6 +230,7 @@ async function waitForProcessExit( export class CodexLocalAgentDriver implements LocalAgentDriver { readonly provider = "codex" as const; + readonly providerInstanceId = "codex"; readonly idleTimeoutMs = 5 * 60_000; private commandResolved = false; diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 6cf19515a..acb9a8b8c 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -3,11 +3,12 @@ import { isSubagentProviderEnabled, localAgentProviderConfigRevision, localAgentProviderEnvironment, + parseSubagentsConfig, subagentProviderConfig, subagentsConfigSchema, } from "./local-agent-config.js"; -const config = subagentsConfigSchema.parse({ +const config = parseSubagentsConfig({ enabled: true, providers: [ { @@ -27,13 +28,14 @@ assert.deepEqual(config, { providers: [ { id: "codex", + driver: "codex", enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: false, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: false, model: "sonnet" }, ], }); assert.equal(isSubagentProviderEnabled(config, "codex"), true); @@ -63,7 +65,7 @@ assert.deepEqual(inherited, { }); assert.equal( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ enabled: true, providers: [ { id: "claude", enabled: false, model: "sonnet" }, @@ -81,7 +83,7 @@ assert.equal( ); assert.notEqual( localAgentProviderConfigRevision(config), - localAgentProviderConfigRevision(subagentsConfigSchema.parse({ + localAgentProviderConfigRevision(parseSubagentsConfig({ ...config, providers: config.providers.map((provider) => provider.id === "codex" ? { ...provider, command: "/opt/bin/another-wrapper" } @@ -100,8 +102,19 @@ assert.throws( enabled: true, providers: [{ id: "unknown", enabled: true }], }), - /Invalid discriminator value/, + /must declare a driver/, ); +const namedInstance = parseSubagentsConfig({ + enabled: true, + providers: [{ id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }], +}); +assert.deepEqual(namedInstance.providers[0], { + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", +}); +assert.equal(subagentProviderConfig(namedInstance, "codex-work")?.driver, "codex"); assert.throws( () => subagentsConfigSchema.parse({ enabled: true, @@ -124,7 +137,7 @@ assert.throws( /Invalid environment variable name/, ); for (const id of ["opencode", "pi"] as const) { - const embedded = subagentsConfigSchema.parse({ + const embedded = parseSubagentsConfig({ enabled: true, providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 39d42f09d..e279bd184 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,8 +1,11 @@ import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentDriverKind, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; const environmentSchema = z.record( z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Invalid environment variable name"), @@ -22,24 +25,36 @@ const commandSchema = z.string() .min(1) .optional(); -const providerSchema = z.discriminatedUnion("id", [ - z.object({ - id: z.enum(["codex", "claude", "cursor", "copilot", "grok"]), - ...providerShape, - command: commandSchema, - }).strict(), - z.object({ - id: z.enum(["opencode", "pi"]), - ...providerShape, - }).strict(), -]); +const providerSchema = z.object({ + id: z.string().trim().min(1), + driver: z.enum(LOCAL_AGENT_DRIVER_KINDS).optional(), + ...providerShape, + command: commandSchema, +}).strict().superRefine((provider, context) => { + const driver = provider.driver ?? (isLocalAgentDriverKind(provider.id) ? provider.id : undefined); + if (!driver) { + context.addIssue({ + code: "custom", + path: ["driver"], + message: `Subagent provider instance ${provider.id} must declare a driver.`, + }); + return; + } + if ((driver === "opencode" || driver === "pi") && provider.command !== undefined) { + context.addIssue({ + code: "custom", + path: ["command"], + message: `${driver} does not support a command override.`, + }); + } +}); export const subagentsConfigSchema = z.object({ enabled: z.boolean(), instructions: z.enum(["on-demand", "preload"]).default("on-demand"), providers: z.array(providerSchema), }).strict().superRefine((value, context) => { - const seen = new Set(); + const seen = new Set(); for (const [index, provider] of value.providers.entries()) { if (seen.has(provider.id)) { context.addIssue({ @@ -57,46 +72,69 @@ export const storedSubagentsConfigSchema = z.union([ subagentsConfigSchema, ]); -export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; +type ParsedSubagentProviderConfig = z.infer; +type ParsedSubagentsConfig = z.infer; + +export interface SubagentProviderConfig extends Omit { + driver: LocalAgentDriverKind; +} + +export interface SubagentsConfig extends Omit { + providers: SubagentProviderConfig[]; +} + export type StoredSubagentsConfig = z.infer; +export function resolveSubagentsConfig(config: ParsedSubagentsConfig): SubagentsConfig { + return { + ...config, + providers: config.providers.map((provider) => ({ + ...provider, + driver: provider.driver ?? provider.id as LocalAgentDriverKind, + })), + }; +} + +export function parseSubagentsConfig(value: unknown): SubagentsConfig { + return resolveSubagentsConfig(subagentsConfigSchema.parse(value)); +} + export function subagentProviderConfig( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): SubagentProviderConfig | undefined { - return config.providers.find((entry) => entry.id === provider); + return config.providers.find((entry) => entry.id === providerInstanceId); } export function isSubagentProviderEnabled( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): boolean { - return config.enabled && subagentProviderConfig(config, provider)?.enabled === true; + return config.enabled && subagentProviderConfig(config, providerInstanceId)?.enabled === true; } export function localAgentProviderEnvironment( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, inherited: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - const providerConfig = subagentProviderConfig(config, provider); + const providerConfig = subagentProviderConfig(config, providerInstanceId); const env = { ...inherited, ...providerConfig?.env }; - const commandVariable = providerCommandVariable(provider); - const command = providerConfig && "command" in providerConfig ? providerConfig.command : undefined; + const commandVariable = providerConfig ? providerCommandVariable(providerConfig.driver) : undefined; + const command = providerConfig?.command; if (commandVariable && command) env[commandVariable] = command; return env; } export function localAgentProviderEnvironmentOverrides( config: SubagentsConfig, - provider: LocalAgentProvider, + providerInstanceId: LocalAgentProviderInstanceId, ): Record { - return { ...subagentProviderConfig(config, provider)?.env }; + return { ...subagentProviderConfig(config, providerInstanceId)?.env }; } -export function providerCommandVariable(provider: LocalAgentProvider): string | undefined { - switch (provider) { +export function providerCommandVariable(driver: LocalAgentDriverKind): string | undefined { + switch (driver) { case "codex": return "CODEX_COMMAND"; case "claude": return "CLAUDE_COMMAND"; case "cursor": return "CURSOR_COMMAND"; @@ -113,6 +151,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin .sort((left, right) => left.id.localeCompare(right.id)) .map((provider) => ({ id: provider.id, + driver: provider.driver, enabled: provider.enabled, ...(provider.model ? { model: provider.model } : {}), ...(provider.effort ? { effort: provider.effort } : {}), diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 5ac32b93b..82573c779 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 6; export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock"; export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid"; export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index bd340d5b5..7b7a92488 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -119,7 +119,8 @@ const record = decodeAgentRecord({ workspaceId: "ws_test", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "idle", latestResponse: " response whitespace \n", createdAt: "now", @@ -168,7 +169,8 @@ const failedRecord = decodeAgentRecord({ workspaceId: "ws_error", workspaceRoot: "/tmp/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "error", error: "Timed out waiting for the local agent daemon.", errorCode: "DAEMON_TIMEOUT", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 21aeb6806..a9f2b2fc1 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -10,6 +10,7 @@ import type { } from "./local-agent-manager.js"; import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentDaemonMethod = | "hello" @@ -213,12 +214,17 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { const record = asRecord(value); const status = requiredString(record?.status, "status"); if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); + const driver = requiredString(record?.driver, "driver"); + if (!isLocalAgentDriverKind(driver)) { + throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent driver."); + } return { id: requiredString(record?.id, "id"), workspaceId: optionalString(record?.workspaceId), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), profileName: requiredString(record?.profileName, "profileName"), - provider: requiredString(record?.provider, "provider"), + providerInstanceId: requiredString(record?.providerInstanceId, "providerInstanceId"), + driver, model: optionalString(record?.model), effort: optionalString(record?.effort), providerSessionId: optionalString(record?.providerSessionId), diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 88722dafa..8985bdc70 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -30,7 +30,8 @@ const record: LocalAgentRecord = { workspaceId: "ws_test", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", status: "running", createdAt: "now", updatedAt: "now", diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 32c33cd77..21c92402e 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -5,9 +5,9 @@ import { type Result as BetterResult, } from "better-result"; import { - isLocalAgentProvider, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + type LocalAgentDriverKind, +} from "./local-agent-provider.js"; export type AgentTargetErrorCode = | "UNKNOWN_TARGET" @@ -19,7 +19,7 @@ export type AgentTargetErrorCode = export class AgentTargetError extends TaggedError("AgentTargetError")<{ code: AgentTargetErrorCode; target: string; - provider?: LocalAgentProvider; + provider?: string; operation?: string; retryable: boolean; cause?: unknown; @@ -50,7 +50,7 @@ export class AgentScopeError extends TaggedError("AgentScopeError")<{ }>() {} interface AgentProviderErrorFields extends Record { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; retryable: boolean; @@ -165,7 +165,7 @@ export interface AgentErrorPayload { code: LocalAgentError["code"]; message: string; retryable?: boolean; - provider?: LocalAgentProvider; + provider?: string; agentId?: string; workspaceId?: string; operation?: string; @@ -233,7 +233,7 @@ export function agentErrorFromPayload(payload: { target?: string; }): LocalAgentError | undefined { const retryable = payload.retryable ?? false; - const provider = payload.provider && isLocalAgentProvider(payload.provider) + const provider = payload.provider && isLocalAgentDriverKind(payload.provider) ? payload.provider : undefined; switch (payload.code) { @@ -245,7 +245,7 @@ export function agentErrorFromPayload(payload: { return new AgentTargetError({ code: payload.code, target: payload.target ?? payload.agentId ?? payload.provider ?? "unknown", - provider, + provider: payload.provider, operation: payload.operation, retryable, message: payload.message, @@ -363,7 +363,7 @@ export function agentErrorFromPayload(payload: { } export function providerErrorFromCause(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; cause: unknown; @@ -406,7 +406,7 @@ export function providerErrorFromCause(input: { } export async function captureAgentProviderResult(input: { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; agentId?: string; operation: string; run: () => T | Promise; @@ -456,7 +456,7 @@ function unavailableCauseKind(error: unknown): "permanent" | "transient" | undef return undefined; } -function displayProvider(provider: LocalAgentProvider): string { +function displayProvider(provider: LocalAgentDriverKind): string { switch (provider) { case "codex": return "Codex"; case "claude": return "Claude"; diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 0df247006..3f11deff8 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -42,8 +42,9 @@ const subagents: SubagentsConfig = { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true, model: "gpt-default", effort: "medium" }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, + { id: "claude", driver: "claude", enabled: true }, ], }; @@ -96,6 +97,7 @@ class FakeRuntime implements LocalAgentRuntime { const runtimes = new Map(); const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, createRuntime: async (context) => { @@ -104,6 +106,7 @@ const driver: LocalAgentDriver = { return Result.ok(runtime); }, }; +const workDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-work" }; function providerFailure(message: string): AgentProviderExecutionError { return new AgentProviderExecutionError({ @@ -120,14 +123,15 @@ const stale = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); const staleTurn = store.beginTurn(stale.id, { prompt: "interrupted turn" }); store.update(stale.id, { latestResponse: "previous response" }); const manager = new LocalAgentManager({ store, - drivers: [driver], + drivers: [driver, workDriver], pool: new LocalAgentRuntimePool(), loadProfiles: async () => [profile, disabledProfile], allowedRoots: [root], @@ -174,6 +178,17 @@ const unknown = await manager.start({ assert.equal(unknown.isErr(), true); if (unknown.isErr()) assert.equal(unknown.error.code, "UNKNOWN_TARGET"); +const namedInstance = unwrap(await manager.start({ + target: "codex-work", + prompt: "inspect named instance", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +assert.equal(namedInstance.providerInstanceId, "codex-work"); +assert.equal(namedInstance.driver, "codex"); +assert.equal(namedInstance.model, "gpt-work"); +await waitFor(() => getRecord(namedInstance.id).status === "idle"); + const disabled = await manager.start({ target: "disabled-reviewer", prompt: "inspect", @@ -199,13 +214,14 @@ const disabledProvider = await manager.start({ workspaceRoot: root, }); assert.equal(disabledProvider.isErr(), true); -if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "PROVIDER_DISABLED"); +if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "UNKNOWN_TARGET"); const previouslyCreatedDisabled = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, profileName: disabledProfile.name, - provider: "codex", + providerInstanceId: "codex", + driver: "codex", }); store.update(previouslyCreatedDisabled.id, { status: "idle" }); const disabledContinuation = await manager.continue(previouslyCreatedDisabled.id, "inspect", {}, scope); @@ -277,7 +293,7 @@ const second = unwrap(await manager.start({ })); await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); -assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); +assert.equal(runtimes.size, 3, "different agents receive independent logical runtimes"); const failed = unwrap(await manager.start({ target: "reviewer", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 30fe1e1f4..f5a17986e 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -11,9 +11,8 @@ import { } from "./local-agent-errors.js"; import { type LocalAgentProfile, - type LocalAgentProvider, - isLocalAgentProvider, } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import { resolveLocalAgentTarget, } from "./local-agent-targets.js"; @@ -34,6 +33,7 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { assertAllowedPath } from "./roots.js"; import { isSubagentProviderEnabled, + subagentProviderConfig, type SubagentsConfig, } from "./local-agent-config.js"; @@ -92,7 +92,7 @@ interface ActiveLocalAgentTurn { */ export class LocalAgentManager { private readonly store: LocalAgentStore; - private readonly drivers = new Map(); + private readonly drivers = new Map(); private readonly pool: LocalAgentRuntimePool; private readonly loadProfiles: (workspaceRoot: string) => Promise; private readonly agentDir?: string; @@ -105,7 +105,7 @@ export class LocalAgentManager { constructor(options: LocalAgentManagerOptions) { this.store = options.store; - for (const driver of options.drivers) this.drivers.set(driver.provider, driver); + for (const driver of options.drivers) this.drivers.set(driver.providerInstanceId, driver); this.pool = options.pool; this.loadProfiles = options.loadProfiles; this.agentDir = options.agentDir; @@ -153,12 +153,13 @@ export class LocalAgentManager { })); } yield* manager.providerEnabledResult(target.provider, target.name, "start"); - yield* manager.driverResult(target.provider, "start"); + const driver = yield* manager.driverResult(target.provider, "start"); const record = yield* manager.store.createResult({ workspaceId: input.workspaceId, workspaceRoot, profileName: target.name, - provider: target.provider, + providerInstanceId: target.provider, + driver: driver.provider, model: target.model, effort: target.effort, }); @@ -184,8 +185,8 @@ export class LocalAgentManager { yield* manager.agentWorkspaceResult(record, scope, "continue"); const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); yield* manager.profileForRecordResult(record, profiles); - yield* manager.providerEnabledResult(record.provider, record.profileName, "continue"); - yield* manager.driverResult(record.provider, "continue", agentId); + yield* manager.providerEnabledResult(record.providerInstanceId, record.profileName, "continue"); + yield* manager.driverResult(record.providerInstanceId, "continue", agentId, record.driver); return manager.begin(record, prompt, overrides, scope.workspaceId); }); } @@ -331,7 +332,8 @@ export class LocalAgentManager { ): Promise { const startedAt = Date.now(); this.log("info", "agent_run_started", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); @@ -360,13 +362,14 @@ export class LocalAgentManager { this.persistRunError(record, turnId, input.error, startedAt); return; } - const driver = this.driverResult(record.provider, "run", record.id); + const driver = this.driverResult(record.providerInstanceId, "run", record.id, record.driver); if (driver.isErr()) { this.persistRunError(record, turnId, driver.error, startedAt); return; } const context: LocalAgentRuntimeContext = { agentId: record.id, + providerInstanceId: record.providerInstanceId, provider: driver.value.provider, workspaceRoot, providerSessionId: record.providerSessionId, @@ -400,7 +403,8 @@ export class LocalAgentManager { }); if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { - provider: updated.value.provider, + providerInstanceId: updated.value.providerInstanceId, + driver: updated.value.driver, agentId: updated.value.id, providerSessionIdPrefix: updated.value.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -417,7 +421,8 @@ export class LocalAgentManager { errorRetryable: false, }); this.log("error", "agent_run_failed", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -444,7 +449,8 @@ export class LocalAgentManager { errorRetryable: error.retryable, }); this.log("error", "agent_run_failed", { - provider: record.provider, + providerInstanceId: record.providerInstanceId, + driver: record.driver, agentId: record.id, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), @@ -461,12 +467,12 @@ export class LocalAgentManager { prompt: string, overrides: RunOverrides, ): BetterResult { - const isRawProvider = record.profileName === record.provider; + const isRawProvider = record.profileName === record.providerInstanceId; if (!profile && !isRawProvider) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -489,13 +495,13 @@ export class LocalAgentManager { record: LocalAgentRecord, profiles: readonly LocalAgentProfile[], ): BetterResult { - if (record.profileName === record.provider) return Result.ok(undefined); + if (record.profileName === record.providerInstanceId) return Result.ok(undefined); const profile = profiles.find((candidate) => candidate.name === record.profileName); if (!profile) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: record.profileName, - provider: isLocalAgentProvider(record.provider) ? record.provider : undefined, + provider: record.providerInstanceId, retryable: false, message: `Subagent profile not found: ${record.profileName}.`, })); @@ -513,47 +519,59 @@ export class LocalAgentManager { } private driverResult( - provider: string, + providerInstanceId: string, operation: string, agentId?: string, + expectedDriver?: LocalAgentDriverKind, ): BetterResult { - if (!isLocalAgentProvider(provider)) { + const driver = this.drivers.get(providerInstanceId); + if (!driver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `No local agent driver is configured for provider instance: ${providerInstanceId}.`, })); } - const driver = this.drivers.get(provider); - if (!driver) { + if (expectedDriver && driver.provider !== expectedDriver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", - target: agentId ?? provider, - provider, + target: agentId ?? providerInstanceId, + provider: providerInstanceId, operation, retryable: false, - message: `No local agent driver is configured for provider: ${provider}.`, + message: `Provider instance ${providerInstanceId} now uses ${driver.provider}; this agent was created with ${expectedDriver}.`, })); } return Result.ok(driver); } private providerEnabledResult( - provider: string, + providerInstanceId: string, target: string, operation: string, ): BetterResult { - if (!isLocalAgentProvider(provider)) return Result.ok(undefined); - if (isSubagentProviderEnabled(this.subagents, provider)) return Result.ok(undefined); + const configured = subagentProviderConfig(this.subagents, providerInstanceId); + if (!configured) { + return Result.err(new AgentTargetError({ + code: "PROVIDER_NOT_CONFIGURED", + target, + provider: providerInstanceId, + operation, + retryable: false, + message: `Subagent provider instance is not configured: ${providerInstanceId}.`, + })); + } + if (isSubagentProviderEnabled(this.subagents, providerInstanceId)) return Result.ok(undefined); return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", target, - provider, + provider: providerInstanceId, operation, retryable: false, - message: `Subagent provider is disabled: ${provider}.`, + message: `Subagent provider is disabled: ${providerInstanceId}.`, })); } diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 69c358004..6b12a1f76 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -58,6 +58,7 @@ const pool = new LocalAgentRuntimePool(); const first = await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -68,6 +69,7 @@ const first = await pool.run(driver, { }); const second = await pool.run(driver, { agentId: "agt_two", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -142,6 +144,7 @@ if (process.platform !== "win32") { }); const created = await envDriver.createRuntime({ agentId: "agt_env", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }); @@ -179,6 +182,7 @@ assert.deepEqual(promptInputs[0], { let callbackSessionId: string | undefined; await pool.run(driver, { agentId: "agt_one", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -283,6 +287,7 @@ const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ })); const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "bad input", workspaceRoot: "/tmp/project" }); @@ -295,6 +300,7 @@ if (applicationFailure.isErr()) { assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "valid input", workspaceRoot: "/tmp/project" }); @@ -312,6 +318,7 @@ const recoveringDriver = new OpencodeLocalAgentDriver(async () => { const recoveringPool = new LocalAgentRuntimePool(); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -321,6 +328,7 @@ await recoveringPool.run(recoveringDriver, { healthAvailable = false; const deadRuntime = await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { @@ -335,6 +343,7 @@ if (deadRuntime.isErr()) { assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately"); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", + providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index d9980aba2..3a7a3eba2 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -149,6 +149,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { export class OpencodeLocalAgentDriver implements LocalAgentDriver { readonly provider = "opencode" as const; + readonly providerInstanceId = "opencode"; readonly idleTimeoutMs = 5 * 60_000; constructor( diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index df111666c..6c1471cbb 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -65,6 +65,7 @@ const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); const context: LocalAgentRuntimeContext = { agentId: "agt_pi", + providerInstanceId: "pi", provider: "pi", workspaceRoot: "/tmp/project", }; diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index bfb85cb3a..2b3780455 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -164,6 +164,7 @@ export class PiSessionRuntime implements LocalAgentRuntime { export class PiLocalAgentDriver implements LocalAgentDriver { readonly provider = "pi" as const; + readonly providerInstanceId = "pi"; readonly idleTimeoutMs = 3 * 60_000; constructor( diff --git a/src/local-agent-presentation.test.ts b/src/local-agent-presentation.test.ts index ea1a4d6e6..fca1e3eae 100644 --- a/src/local-agent-presentation.test.ts +++ b/src/local-agent-presentation.test.ts @@ -13,7 +13,8 @@ const record: LocalAgentRecord = { workspaceId: "ws_private", workspaceRoot: "/private/project", profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex", + driver: "codex", model: "gpt-5.4", effort: "high", providerSessionId: "provider_private", @@ -65,9 +66,10 @@ assert.deepEqual(failed, { const catalog: LocalAgentCatalog = { enabled: true, providers: [ - { id: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, + { id: "codex", driver: "codex", enabled: true, available: true, usable: true, model: "gpt-5.4", effort: "high" }, { id: "claude", + driver: "claude", enabled: true, available: false, usable: false, diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 8a7d57db1..f22258b13 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -76,16 +76,17 @@ try { [ "---", "name: custom", - "description: Unsupported custom agent.", - "provider: custom", + "description: Named provider instance.", + "provider: codex-work", "---", "", "Custom body.", "", ].join("\n"), ); - const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); - assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); + const profilesWithNamedInstance = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.deepEqual(profilesWithNamedInstance.map((profile) => profile.name), ["custom", "reviewer"]); + assert.equal(profilesWithNamedInstance[0]?.provider, "codex-work"); const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [workspaceRoot] }, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index ad99a225c..2c55f824a 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -3,23 +3,12 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; - -export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot" | "grok"; - -export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ - "codex", - "claude", - "opencode", - "pi", - "cursor", - "copilot", - "grok", -]; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; export interface LocalAgentProfile { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; filePath: string; @@ -30,7 +19,7 @@ export interface LocalAgentProfile { export interface LocalAgentProfileSummary { name: string; description: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; } @@ -41,7 +30,6 @@ interface ParsedFrontmatter { } const FRONTMATTER_DELIMITER = "---"; -const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); export async function loadLocalAgentProfiles( config: ServerConfig, @@ -154,21 +142,12 @@ function profileFromFrontmatter( }; } -function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { +function readProvider(frontmatter: Record, filePath: string): LocalAgentProviderInstanceId { const provider = readString(frontmatter, "provider"); if (!provider) { throw new Error(`Subagent profile is missing provider: ${filePath}`); } - if (!PROVIDERS.has(provider as LocalAgentProvider)) { - throw new Error( - `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, or grok: ${filePath}`, - ); - } - return provider as LocalAgentProvider; -} - -export function isLocalAgentProvider(value: string): value is LocalAgentProvider { - return PROVIDERS.has(value as LocalAgentProvider); + return provider; } function readString(frontmatter: Record, key: string): string | undefined { diff --git a/src/local-agent-provider.ts b/src/local-agent-provider.ts new file mode 100644 index 000000000..8506463c3 --- /dev/null +++ b/src/local-agent-provider.ts @@ -0,0 +1,20 @@ +export const LOCAL_AGENT_DRIVER_KINDS = [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok", +] as const; + +export type LocalAgentDriverKind = typeof LOCAL_AGENT_DRIVER_KINDS[number]; + +/** User-configured routing identity. Multiple instances may share one driver. */ +export type LocalAgentProviderInstanceId = string; + +const DRIVER_KINDS = new Set(LOCAL_AGENT_DRIVER_KINDS); + +export function isLocalAgentDriverKind(value: string): value is LocalAgentDriverKind { + return DRIVER_KINDS.has(value); +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index a03150240..213223555 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -12,7 +12,7 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60_000; @@ -209,7 +209,7 @@ export class LocalAgentRuntimePool { private async discardRuntime( entry: RuntimeEntry, - provider: LocalAgentProvider, + provider: LocalAgentDriverKind, reason: string, ): Promise { try { diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index da2364005..a21513b9b 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -17,6 +17,7 @@ import type { const context: LocalAgentRuntimeContext = { agentId: "agt_test", + providerInstanceId: "codex", provider: "codex", workspaceRoot: "/tmp/project", }; @@ -92,6 +93,7 @@ class FakeRuntime implements LocalAgentRuntime { const runtime = new FakeRuntime(); let createCount = 0; const driver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "shared", @@ -132,6 +134,7 @@ const sessionPool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const sessionDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "session-runtime", @@ -160,6 +163,7 @@ const shutdownReleasePool = new LocalAgentRuntimePool({ sessionIdleTimeoutMs: 10, }); const shutdownReleaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "shutdown-release-runtime", @@ -209,6 +213,7 @@ class CleanupFailureRuntime extends FakeRuntime { const cleanupPool = new LocalAgentRuntimePool(); const cleanupRuntime = new CleanupFailureRuntime(); const cleanupDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "cleanup-runtime", createRuntime: async () => Result.ok(cleanupRuntime), @@ -225,6 +230,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider let attempts = 0; const recoveryPool = new LocalAgentRuntimePool(); const recoveryDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "dead-runtime-recovery", createRuntime: async () => Result.ok(attempts++ === 0 ? deadRuntime : replacementRuntime), @@ -249,6 +255,7 @@ if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider const completedTurnRuntime = new ClosingAfterTurnRuntime(); completedTurnPool = new LocalAgentRuntimePool(); const completedTurnDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "completed-turn-during-close", createRuntime: async () => Result.ok(completedTurnRuntime), @@ -265,6 +272,7 @@ const creating = new Promise const raceRuntime = new FakeRuntime(); const racePool = new LocalAgentRuntimePool(); const raceDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "creation-race", createRuntime: () => creating, @@ -296,6 +304,7 @@ if (afterClose.isErr()) { const recoveryRuntime = new FakeRuntime(); const creationPool = new LocalAgentRuntimePool(); const creationDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", runtimeKey: () => "creation-failure", async createRuntime() { @@ -349,6 +358,7 @@ if (afterClose.isErr()) { sessionIdleTimeoutMs: 10, }); const releaseDriver: LocalAgentDriver = { + providerInstanceId: "codex", provider: "codex", idleTimeoutMs: Number.POSITIVE_INFINITY, runtimeKey: () => "release-failure", diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index ecc40f545..bd1b0982b 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,6 +1,9 @@ import type { Result } from "better-result"; import type { AgentProviderError } from "./local-agent-errors.js"; -import type { LocalAgentProvider } from "./local-agent-profiles.js"; +import type { + LocalAgentDriverKind, + LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -16,7 +19,7 @@ export interface LocalAgentRunInput { } export interface LocalAgentRunResult { - provider: LocalAgentProvider; + provider: LocalAgentDriverKind; providerSessionId: string | null; finalResponse: string; items: unknown[]; @@ -33,7 +36,8 @@ export interface LocalAgentRunCallbacks { export interface LocalAgentRuntimeContext { agentId: string; - provider: LocalAgentProvider; + providerInstanceId: LocalAgentProviderInstanceId; + provider: LocalAgentDriverKind; workspaceRoot: string; providerSessionId?: string; writeMode?: LocalAgentWriteMode; @@ -48,7 +52,7 @@ export interface LocalAgentRuntimeContext { * identity used when a later runtime is created. */ export interface LocalAgentRuntime { - readonly provider: LocalAgentProvider; + readonly provider: LocalAgentDriverKind; run( input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks, @@ -59,7 +63,8 @@ export interface LocalAgentRuntime { } export interface LocalAgentDriver { - readonly provider: LocalAgentProvider; + readonly providerInstanceId: LocalAgentProviderInstanceId; + readonly provider: LocalAgentDriverKind; runtimeKey(context: LocalAgentRuntimeContext): string; createRuntime(context: LocalAgentRuntimeContext): Promise>; readonly idleTimeoutMs?: number; diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index e29bd319b..04e9d2a9a 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -16,7 +16,8 @@ try { workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "reviewer", - provider: "codex", + providerInstanceId: "codex-work", + driver: "codex", model: "gpt-5.4", effort: "high", }); @@ -25,6 +26,8 @@ try { assert.equal(created.status, "starting"); assert.equal(store.getById(created.id)?.effort, "high"); assert.equal(store.getById(created.id)?.profileName, "reviewer"); + assert.equal(store.getById(created.id)?.providerInstanceId, "codex-work"); + assert.equal(store.getById(created.id)?.driver, "codex"); assert.equal(store.getById(created.id.slice(0, 7)), undefined); const updated = store.update(created.id, { @@ -130,7 +133,8 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot workspaceId: "ws_1", workspaceRoot: join(root, "project"), profileName: "explorer", - provider: "claude", + providerInstanceId: "claude", + driver: "claude", }); assert.deepEqual( @@ -194,6 +198,8 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot const legacyRecord = upgradedStore.getById("agt_legacy"); assert.equal(legacyRecord?.error, "old error"); assert.equal(legacyRecord?.effort, "high"); + assert.equal(legacyRecord?.providerInstanceId, "codex"); + assert.equal(legacyRecord?.driver, "codex"); assert.equal(legacyRecord?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); const upgradedRecord = upgradedStore.update("agt_legacy", { diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 3aab64da6..83336aa19 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { AgentStoreError, isProgrammerDefect } from "./local-agent-errors.js"; +import type { LocalAgentDriverKind } from "./local-agent-provider.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; @@ -12,7 +13,8 @@ export interface LocalAgentRecord { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; providerSessionId?: string; @@ -29,7 +31,8 @@ export interface CreateLocalAgentRecordInput { workspaceId?: string; workspaceRoot: string; profileName: string; - provider: string; + providerInstanceId: string; + driver: LocalAgentDriverKind; model?: string; effort?: string; } @@ -78,7 +81,8 @@ interface LocalAgentRow { workspace_id: string | null; workspace_root: string; profile_name: string; - provider: string; + provider_instance_id: string; + driver: LocalAgentDriverKind; model: string | null; effort: string | null; provider_session_id: string | null; @@ -157,7 +161,8 @@ export class LocalAgentStore { workspaceId: input.workspaceId, workspaceRoot: resolve(input.workspaceRoot), profileName: input.profileName, - provider: input.provider, + providerInstanceId: input.providerInstanceId, + driver: input.driver, model: input.model, effort: input.effort, status: "starting", @@ -172,20 +177,22 @@ export class LocalAgentStore { workspace_id, workspace_root, profile_name, - provider, + provider_instance_id, + driver, model, effort, status, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( record.id, record.workspaceId ?? null, record.workspaceRoot, record.profileName, - record.provider, + record.providerInstanceId, + record.driver, record.model ?? null, record.effort ?? null, record.status, @@ -239,7 +246,8 @@ export class LocalAgentStore { workspace_id = ?, workspace_root = ?, profile_name = ?, - provider = ?, + provider_instance_id = ?, + driver = ?, model = ?, effort = ?, provider_session_id = ?, @@ -255,7 +263,8 @@ export class LocalAgentStore { updated.workspaceId ?? null, resolve(updated.workspaceRoot), updated.profileName, - updated.provider, + updated.providerInstanceId, + updated.driver, updated.model ?? null, updated.effort ?? null, updated.providerSessionId ?? null, @@ -463,7 +472,8 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { workspaceId: row.workspace_id ?? undefined, workspaceRoot: row.workspace_root, profileName: row.profile_name, - provider: row.provider, + providerInstanceId: row.provider_instance_id, + driver: row.driver, model: row.model ?? undefined, effort: row.effort ?? undefined, providerSessionId: row.provider_session_id ?? undefined, diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index fde0c000e..ff7a197cb 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -106,7 +106,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, undefined, undefined, providers); assert.equal(target?.kind, "provider"); assert.equal(target?.name, "opencode"); assert.equal(target?.provider, "opencode"); @@ -115,7 +116,8 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { } { - const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep"); + const providers = [{ id: "opencode", driver: "opencode", enabled: true }] as const; + const target = resolveLocalAgentTarget("opencode", profiles, "kimi-k2", "deep", providers); assert.equal(target?.kind, "provider"); assert.equal(target?.model, "kimi-k2"); assert.equal(target?.effort, "deep"); @@ -124,6 +126,7 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { { const providerDefaults = [{ id: "codex", + driver: "codex", enabled: true, model: "gpt-default", effort: "medium", @@ -139,6 +142,19 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { assert.equal(overridden?.effort, "xhigh"); } +{ + const providerInstances = [{ + id: "codex-work", + driver: "codex", + enabled: true, + model: "gpt-work", + }] as const; + const target = resolveLocalAgentTarget("codex-work", profiles, undefined, undefined, providerInstances); + assert.equal(target?.kind, "provider"); + assert.equal(target?.provider, "codex-work"); + assert.equal(target?.model, "gpt-work"); +} + { const target = resolveLocalAgentTarget("claude", profiles); assert.equal(target?.kind, "profile"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 367b04761..d3794810c 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,9 +1,5 @@ -import { - isLocalAgentProvider, - LOCAL_AGENT_PROVIDERS, - type LocalAgentProfile, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; import type { SubagentProviderConfig } from "./local-agent-config.js"; export interface ParsedLocalAgentRunArgs { @@ -24,15 +20,15 @@ export type LocalAgentTarget = | { kind: "profile"; name: string; - provider: LocalAgentProvider; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; profile: LocalAgentProfile; } | { kind: "provider"; - name: LocalAgentProvider; - provider: LocalAgentProvider; + name: LocalAgentProviderInstanceId; + provider: LocalAgentProviderInstanceId; model?: string; effort?: string; }; @@ -143,8 +139,8 @@ export function resolveLocalAgentTarget( }; } - if (isLocalAgentProvider(target)) { - const providerConfig = providerConfigs.find((entry) => entry.id === target); + const providerConfig = providerConfigs.find((entry) => entry.id === target); + if (providerConfig) { return { kind: "provider", name: target, diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 90a8ade25..0f7c7aedc 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -52,6 +52,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, + { version: 9, name: "local-agent-provider-instances" }, ]); } finally { database.close(); diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index 9a5d02e8c..48049943e 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -22,8 +22,8 @@ assert.deepEqual( enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: true }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: true }, ], }, ); @@ -34,30 +34,34 @@ const configured = { providers: [ { id: "codex" as const, + driver: "codex" as const, enabled: true, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude" as const, enabled: true, model: "sonnet" }, + { id: "claude" as const, driver: "claude" as const, enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex" as const, enabled: true, model: "gpt-work" }, ], }; assert.deepEqual( - updateOnboardingSubagentsConfig(configured, ["claude"]), + updateOnboardingSubagentsConfig(configured, ["claude", "codex-work"]), { enabled: true, instructions: "preload", providers: [ { id: "codex", + driver: "codex", enabled: false, model: "gpt-5.4", effort: "high", command: "/opt/bin/codex-wrapper", env: { OPENAI_API_KEY: "configured", EMPTY_VALUE: "" }, }, - { id: "claude", enabled: true, model: "sonnet" }, + { id: "claude", driver: "claude", enabled: true, model: "sonnet" }, + { id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }, ], }, ); diff --git a/src/onboarding.ts b/src/onboarding.ts index 193dd57dd..fcf26931a 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,8 +1,9 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { - LOCAL_AGENT_PROVIDERS, - type LocalAgentProvider, -} from "./local-agent-profiles.js"; + isLocalAgentDriverKind, + LOCAL_AGENT_DRIVER_KINDS, + type LocalAgentProviderInstanceId, +} from "./local-agent-provider.js"; export const SUBAGENT_SKILL_INSTALL_COMMAND = "npx skills add Waishnav/devspace --skill subagents --global"; @@ -31,21 +32,28 @@ export function usesCodingAgents(usage: OnboardingUsage): boolean { export function updateOnboardingSubagentsConfig( current: SubagentsConfig, - selectedProviders: readonly LocalAgentProvider[], + selectedProviders: readonly LocalAgentProviderInstanceId[], ): SubagentsConfig { const selected = new Set(selectedProviders); + const customInstances = current.providers + .filter((provider) => !isLocalAgentDriverKind(provider.id)) + .map((provider) => ({ ...provider, enabled: selected.has(provider.id) })); return { enabled: true, instructions: current.instructions, - providers: LOCAL_AGENT_PROVIDERS + providers: [ + ...LOCAL_AGENT_DRIVER_KINDS .filter((id) => selected.has(id) || current.providers.some((provider) => provider.id === id)) .map((id) => { const existing = current.providers.find((provider) => provider.id === id); return { ...existing, id, + driver: existing?.driver ?? id, enabled: selected.has(id), }; }), + ...customInstances, + ], }; } diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..d93515a61 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -12,6 +12,7 @@ import { loadConfig, type ServerConfig, type ToolMode } from "./config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; import type { SubagentsConfig } from "./local-agent-config.js"; +import { isLocalAgentDriverKind } from "./local-agent-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer, createServer } from "./server.js"; @@ -415,8 +416,8 @@ test("open_workspace omits providers disabled by configuration", async (t) => { enabled: true, instructions: "on-demand", providers: [ - { id: "codex", enabled: true }, - { id: "claude", enabled: false }, + { id: "codex", driver: "codex", enabled: true }, + { id: "claude", driver: "claude", enabled: false }, ], }, }); @@ -445,7 +446,7 @@ test("open_workspace preloads subagent instructions when configured", async (t) subagents: { enabled: true, instructions: "preload", - providers: [{ id: "codex", enabled: true }], + providers: [{ id: "codex", driver: "codex", enabled: true }], }, }); @@ -775,10 +776,12 @@ async function fixture( subagents: options.subagents ?? { enabled: true, instructions: "on-demand", - providers: initialProviderAvailability.map((provider) => ({ - id: provider.name, - enabled: true, - })), + providers: initialProviderAvailability.map((provider) => { + if (!isLocalAgentDriverKind(provider.name)) { + throw new Error(`Test provider instance requires explicit config: ${provider.name}`); + } + return { id: provider.name, driver: provider.name, enabled: true }; + }), }, } : modeConfig;