diff --git a/bun.lock b/bun.lock index 356a76ed8d..73b763445d 100644 --- a/bun.lock +++ b/bun.lock @@ -129,6 +129,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/paths": "workspace:*", + "@github/copilot-sdk": "^0.2.2", "@openai/codex-sdk": "^0.116.0", }, "devDependencies": { @@ -452,6 +453,22 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@github/copilot": ["@github/copilot@1.0.24", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.24", "@github/copilot-darwin-x64": "1.0.24", "@github/copilot-linux-arm64": "1.0.24", "@github/copilot-linux-x64": "1.0.24", "@github/copilot-win32-arm64": "1.0.24", "@github/copilot-win32-x64": "1.0.24" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-/nZ2GwhaGq0HeI3W+6LE0JGw25/bipC6tYVa+oQ5tIvAafBazuNt10CXkeaor+u9oBWLZtPbdTyAzE2tjy9NpQ=="], + + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.24", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-lejn6KV+09rZEICX3nRx9a58DQFQ2kK3NJ3EICfVLngUCWIUmwn1BLezjeTQc9YNasHltA1hulvfsWqX+VjlMw=="], + + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.24", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-r2F3keTvr4Bunz3V+waRAvsHgqsVQGyIZFBebsNPWxBX1eh3IXgtBqxCR7vXTFyZonQ8VaiJH3YYEfAhyKsk9g=="], + + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.24", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-B3oANXKKKLhnKYozXa/W+DxfCQAHJCs0QKR5rBwNrwJbf656twNgALSxWTSJk+1rEP6MrHCswUAcwjwZL7Q+FQ=="], + + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.24", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-NGTldizY54B+4Sfhu/GWoEQNMwqqUNgMwbSgBshFv+Hqy1EwuvNWKVov1Y0Vzhp4qAHc6ZxBk/OPIW8Ato9FUg=="], + + "@github/copilot-sdk": ["@github/copilot-sdk@0.2.2", "", { "dependencies": { "@github/copilot": "^1.0.21", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw=="], + + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.24", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-/pd/kgef7/HIIg1SQq4q8fext39pDSC44jHB10KkhfgG1WaDFhQbc/aSSMQfxeldkRbQh6VANp8WtGQdwtMCBA=="], + + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.24", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-RDvOiSvyEJwELqErwANJTrdRuMIHkwPE4QK7Le7WsmaSKxiuS4H1Pa8Yxnd2FWrMsCHEbase23GJlymbnGYLXQ=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/zod-openapi": ["@hono/zod-openapi@0.19.10", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@hono/zod-validator": "^0.7.1", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": ">=3.0.0" } }, "sha512-dpoS6DenvoJyvxtQ7Kd633FRZ/Qf74+4+o9s+zZI8pEqnbjdF/DtxIib08WDpCaWabMEJOL5TXpMgNEZvb7hpA=="], @@ -2402,6 +2419,8 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -2498,6 +2517,8 @@ "@expressive-code/plugin-shiki/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + "@github/copilot-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], "@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 2f53879931..8717aa52c7 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -46,6 +46,8 @@ interface SetupConfig { claudeOauthToken?: string; codex: boolean; codexTokens?: CodexTokens; + copilot: boolean; + copilotToken?: string; defaultAssistant: string; }; platforms: { @@ -95,6 +97,7 @@ interface ExistingConfig { hasDatabase: boolean; hasClaude: boolean; hasCodex: boolean; + hasCopilot: boolean; platforms: { github: boolean; telegram: boolean; @@ -249,6 +252,7 @@ export function checkExistingConfig(): ExistingConfig | null { hasEnvValue(content, 'CODEX_ACCESS_TOKEN') && hasEnvValue(content, 'CODEX_REFRESH_TOKEN') && hasEnvValue(content, 'CODEX_ACCOUNT_ID'), + hasCopilot: hasEnvValue(content, 'COPILOT_ENABLED'), platforms: { github: hasEnvValue(content, 'GITHUB_TOKEN') || hasEnvValue(content, 'GH_TOKEN'), telegram: hasEnvValue(content, 'TELEGRAM_BOT_TOKEN'), @@ -530,6 +534,48 @@ async function collectCodexAuth(): Promise { }; } +/** + * Collect GitHub Copilot authentication + */ +async function collectCopilotAuth(): Promise { + note( + 'GitHub Copilot Authentication\n\n' + + 'GitHub Copilot uses your existing GitHub CLI authentication.\n\n' + + 'To authenticate:\n' + + '1. Run `gh auth login` in your terminal\n' + + '2. Complete the authentication flow\n\n' + + 'The Copilot SDK will use your existing gh CLI credentials.\n' + + 'No additional tokens are needed.', + 'GitHub Copilot Setup' + ); + + const isAuthenticated = await confirm({ + message: 'Have you authenticated with GitHub CLI (gh auth login)?', + }); + + if (isCancel(isAuthenticated)) { + cancel('Setup cancelled.'); + process.exit(0); + } + + if (!isAuthenticated) { + return null; + } + + // Verify gh CLI is available + if (!isCommandAvailable('gh')) { + note( + 'GitHub CLI (gh) is not installed.\n\n' + + 'Install it from: https://cli.github.com/\n\n' + + 'After installation, run `gh auth login` to authenticate.', + 'GitHub CLI Not Found' + ); + return null; + } + + return 'gh-cli'; +} + /** * Collect AI assistant configuration */ @@ -540,6 +586,7 @@ async function collectAIConfig(): Promise { options: [ { value: 'claude', label: 'Claude (Recommended)', hint: 'Anthropic Claude Code SDK' }, { value: 'codex', label: 'Codex', hint: 'OpenAI Codex SDK' }, + { value: 'copilot', label: 'GitHub Copilot', hint: 'GitHub Copilot SDK' }, ], required: false, }); @@ -551,6 +598,7 @@ async function collectAIConfig(): Promise { let hasClaude = assistants.includes('claude'); let hasCodex = assistants.includes('codex'); + let hasCopilot = assistants.includes('copilot'); // Check if selected CLI tools are installed if (hasClaude && !isCommandAvailable('claude')) { @@ -650,11 +698,12 @@ After upgrading, run 'archon setup' again.`, } } - if (!hasClaude && !hasCodex) { + if (!hasClaude && !hasCodex && !hasCopilot) { log.warning('No AI assistant selected. You can add one later by running `archon setup` again.'); return { claude: false, codex: false, + copilot: false, defaultAssistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude', }; } @@ -663,6 +712,7 @@ After upgrading, run 'archon setup' again.`, let claudeApiKey: string | undefined; let claudeOauthToken: string | undefined; let codexTokens: CodexTokens | undefined; + let copilotToken: string | undefined; // Collect Claude auth if selected if (hasClaude) { @@ -678,11 +728,22 @@ After upgrading, run 'archon setup' again.`, codexTokens = tokens ?? undefined; } + // Collect Copilot auth if selected + if (hasCopilot) { + const token = await collectCopilotAuth(); + if (token === null) { + hasCopilot = false; + } else { + copilotToken = token; + } + } + // Determine default assistant — use the registry, but keep setup/auth flows built-in only. // Default to first registered built-in provider rather than hardcoding 'claude'. let defaultAssistant = getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude'; - if (hasClaude && hasCodex) { + const selectedCount = [hasClaude, hasCodex, hasCopilot].filter(Boolean).length; + if (selectedCount > 1) { const providerChoices = getRegisteredProviders() .filter(p => p.builtIn) .map(p => ({ @@ -701,8 +762,10 @@ After upgrading, run 'archon setup' again.`, } defaultAssistant = defaultChoice; - } else if (hasCodex && !hasClaude) { + } else if (hasCodex && !hasClaude && !hasCopilot) { defaultAssistant = 'codex'; + } else if (hasCopilot && !hasClaude && !hasCodex) { + defaultAssistant = 'copilot'; } return { @@ -712,6 +775,8 @@ After upgrading, run 'archon setup' again.`, claudeOauthToken, codex: hasCodex, codexTokens, + copilot: hasCopilot, + copilotToken, defaultAssistant, }; } @@ -1084,6 +1149,13 @@ export function generateEnvContent(config: SetupConfig): string { lines.push(''); } + if (config.ai.copilot) { + lines.push('# GitHub Copilot'); + lines.push('# Uses GitHub CLI authentication (gh auth login)'); + lines.push('COPILOT_ENABLED=true'); + lines.push(''); + } + // Default AI Assistant lines.push('# Default AI Assistant'); lines.push(`DEFAULT_AI_ASSISTANT=${config.ai.defaultAssistant}`); @@ -1390,6 +1462,7 @@ export async function setupCommand(options: SetupOptions): Promise { `Database: ${existing.hasDatabase ? 'PostgreSQL' : 'SQLite'}`, `Claude: ${existing.hasClaude ? 'Configured' : 'Not configured'}`, `Codex: ${existing.hasCodex ? 'Configured' : 'Not configured'}`, + `Copilot: ${existing.hasCopilot ? 'Configured' : 'Not configured'}`, `Platforms: ${configuredPlatforms.length > 0 ? configuredPlatforms.join(', ') : 'None'}`, ].join('\n'); @@ -1427,6 +1500,7 @@ export async function setupCommand(options: SetupOptions): Promise { ai: { claude: existing?.hasClaude ?? false, codex: existing?.hasCodex ?? false, + copilot: existing?.hasCopilot ?? false, defaultAssistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude', }, platforms: { @@ -1596,6 +1670,9 @@ export async function setupCommand(options: SetupOptions): Promise { if (config.ai.codex && config.ai.codexTokens) { aiConfigured.push('Codex'); } + if (config.ai.copilot) { + aiConfigured.push('GitHub Copilot'); + } const summaryLines = [ `Database: ${config.database.type === 'postgresql' ? 'PostgreSQL' : 'SQLite (default)'}`, diff --git a/packages/core/src/config/config-loader.test.ts b/packages/core/src/config/config-loader.test.ts index 4b0d34314c..837769005e 100644 --- a/packages/core/src/config/config-loader.test.ts +++ b/packages/core/src/config/config-loader.test.ts @@ -224,7 +224,7 @@ concurrency: const config = await loadConfig(); expect(config.assistant).toBe('claude'); - expect(config.assistants).toEqual({ claude: {}, codex: {} }); + expect(config.assistants).toEqual({ claude: {}, codex: {}, copilot: {} }); expect(config.streaming.telegram).toBe('stream'); expect(config.concurrency.maxConversations).toBe(10); }); @@ -245,29 +245,32 @@ streaming: expect(config.streaming.telegram).toBe('batch'); }); - test('throws on unknown DEFAULT_AI_ASSISTANT env var', async () => { + test('accepts copilot as DEFAULT_AI_ASSISTANT env var', async () => { mockReadConfigFile.mockResolvedValue(''); - process.env.DEFAULT_AI_ASSISTANT = 'nonexistent-provider'; + process.env.DEFAULT_AI_ASSISTANT = 'copilot'; - await expect(loadConfig()).rejects.toThrow(/not a registered provider/); + const config = await loadConfig(); + expect(config.assistant).toBe('copilot'); }); - test('throws on unknown defaultAssistant in global config', async () => { - mockReadConfigFile.mockResolvedValue('defaultAssistant: nonexistent-provider'); + test('accepts copilot in global config', async () => { + mockReadConfigFile.mockResolvedValue('defaultAssistant: copilot'); - await expect(loadConfig()).rejects.toThrow(/not a registered provider/); + const config = await loadConfig(); + expect(config.assistant).toBe('copilot'); }); - test('throws on unknown assistant in repo config', async () => { + test('accepts copilot in repo config', async () => { mockReadConfigFile.mockImplementation(async (path: string) => { const normalized = path.replace(/\\/g, '/'); if (normalized.includes('/tmp/test-repo/.archon/config.yaml')) { - return 'assistant: nonexistent-provider'; + return 'assistant: copilot'; } return ''; }); - await expect(loadConfig('/tmp/test-repo')).rejects.toThrow(/not a registered provider/); + const config = await loadConfig('/tmp/test-repo'); + expect(config.assistant).toBe('copilot'); }); test('repo config overrides global config', async () => { diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 2ef1a7b13b..611917a4ab 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -28,66 +28,8 @@ export async function writeConfigFile( ): Promise { await writeFile(path, content, { encoding: 'utf-8', ...options }); } -import type { - GlobalConfig, - RepoConfig, - MergedConfig, - SafeConfig, - AssistantDefaults, - AssistantDefaultsConfig, -} from './config-types'; +import type { GlobalConfig, RepoConfig, MergedConfig, SafeConfig } from './config-types'; import { createLogger } from '@archon/paths'; -import { - isRegisteredProvider, - getRegisteredProviders, - registerBuiltinProviders, -} from '@archon/providers'; - -function getRegisteredProviderNames(): string[] { - registerBuiltinProviders(); - return getRegisteredProviders().map(p => p.id); -} - -function mergeAssistantDefaults( - base: AssistantDefaults, - overrides?: AssistantDefaultsConfig -): AssistantDefaults { - const merged: AssistantDefaults = { - ...base, - claude: { ...(base.claude ?? {}) }, - codex: { ...(base.codex ?? {}) }, - }; - - if (!overrides) return merged; - - for (const [providerId, providerDefaults] of Object.entries(overrides)) { - if (!providerDefaults || typeof providerDefaults !== 'object') continue; - merged[providerId] = { - ...(merged[providerId] ?? {}), - ...providerDefaults, - }; - } - - return merged; -} - -function toSafeAssistantDefaults(assistants: AssistantDefaults): SafeConfig['assistants'] { - const safeAssistants: SafeConfig['assistants'] = {}; - - for (const [providerId, providerDefaults] of Object.entries(assistants)) { - if (!providerDefaults || typeof providerDefaults !== 'object') continue; - const safeDefaults: Record = { ...providerDefaults }; - - // Server-internal or local-path settings should never be exposed to the web UI. - delete safeDefaults.additionalDirectories; - delete safeDefaults.settingSources; - delete safeDefaults.codexBinaryPath; - - safeAssistants[providerId] = safeDefaults; - } - - return safeAssistants; -} /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -115,7 +57,7 @@ const DEFAULT_CONFIG_CONTENT = `# Archon Global Configuration # Bot display name (shown in messages) # botName: Archon -# Default AI assistant (must match a registered provider, e.g. claude, codex) +# Default AI assistant (claude, codex, or copilot) # defaultAssistant: claude # Assistant defaults @@ -128,6 +70,8 @@ const DEFAULT_CONFIG_CONTENT = `# Archon Global Configuration # webSearchMode: disabled # additionalDirectories: # - /absolute/path/to/other/repo +# copilot: +# model: gpt-5-mini # Streaming mode per platform (stream or batch) # streaming: @@ -228,22 +172,14 @@ export async function loadRepoConfig(repoPath: string): Promise { * Get default configuration */ function getDefaults(): MergedConfig { - // Initialize assistant defaults from registered providers rather than hardcoding. - // Built-in providers always exist (registerBuiltinProviders called before loadConfig). - const registeredAssistants: AssistantDefaults = { - claude: {}, - codex: {}, - }; - for (const provider of getRegisteredProviders()) { - if (!(provider.id in registeredAssistants)) { - registeredAssistants[provider.id] = {}; - } - } - return { botName: 'Archon', - assistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude', - assistants: registeredAssistants, + assistant: 'claude', + assistants: { + claude: {}, + codex: {}, + copilot: {}, + }, streaming: { telegram: 'stream', discord: 'batch', @@ -278,17 +214,10 @@ function applyEnvOverrides(config: MergedConfig): MergedConfig { config.botName = envBotName; } - // Assistant override — validate against registry, error on unknown provider + // Assistant override const envAssistant = process.env.DEFAULT_AI_ASSISTANT; - if (envAssistant && envAssistant.length > 0) { - if (isRegisteredProvider(envAssistant)) { - config.assistant = envAssistant; - } else { - throw new Error( - `DEFAULT_AI_ASSISTANT='${envAssistant}' is not a registered provider. ` + - `Available providers: ${getRegisteredProviderNames().join(', ')}` - ); - } + if (envAssistant === 'claude' || envAssistant === 'codex' || envAssistant === 'copilot') { + config.assistant = envAssistant; } // Streaming overrides @@ -329,7 +258,11 @@ function applyEnvOverrides(config: MergedConfig): MergedConfig { function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): MergedConfig { const result: MergedConfig = { ...defaults, - assistants: mergeAssistantDefaults(defaults.assistants), + assistants: { + claude: { ...defaults.assistants.claude }, + codex: { ...defaults.assistants.codex }, + copilot: { ...defaults.assistants.copilot }, + }, }; // Bot name preference @@ -337,19 +270,23 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged result.botName = global.botName; } - // Assistant preference — validate against registry + // Assistant preference if (global.defaultAssistant) { - if (isRegisteredProvider(global.defaultAssistant)) { - result.assistant = global.defaultAssistant; - } else { - throw new Error( - `defaultAssistant: '${global.defaultAssistant}' in global config (~/.archon/config.yaml) ` + - `is not a registered provider. Available: ${getRegisteredProviderNames().join(', ')}` - ); - } + result.assistant = global.defaultAssistant; } - result.assistants = mergeAssistantDefaults(result.assistants, global.assistants); + if (global.assistants?.claude?.model) { + result.assistants.claude.model = global.assistants.claude.model; + } + if (global.assistants?.claude?.settingSources) { + result.assistants.claude.settingSources = global.assistants.claude.settingSources; + } + if (global.assistants?.codex) { + result.assistants.codex = { + ...result.assistants.codex, + ...global.assistants.codex, + }; + } // Streaming preferences if (global.streaming) { @@ -378,22 +315,36 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { const result: MergedConfig = { ...merged, - assistants: mergeAssistantDefaults(merged.assistants), + assistants: { + claude: { ...merged.assistants.claude }, + codex: { ...merged.assistants.codex }, + copilot: { ...merged.assistants.copilot }, + }, }; - // Assistant override (repo-level takes precedence) — validate against registry + // Assistant override (repo-level takes precedence) if (repo.assistant) { - if (isRegisteredProvider(repo.assistant)) { - result.assistant = repo.assistant; - } else { - throw new Error( - `assistant: '${repo.assistant}' in repo config (.archon/config.yaml) ` + - `is not a registered provider. Available: ${getRegisteredProviderNames().join(', ')}` - ); - } + result.assistant = repo.assistant; } - result.assistants = mergeAssistantDefaults(result.assistants, repo.assistants); + if (repo.assistants?.claude?.model) { + result.assistants.claude.model = repo.assistants.claude.model; + } + if (repo.assistants?.claude?.settingSources) { + result.assistants.claude.settingSources = repo.assistants.claude.settingSources; + } + if (repo.assistants?.codex) { + result.assistants.codex = { + ...result.assistants.codex, + ...repo.assistants.codex, + }; + } + if (repo.assistants?.copilot) { + result.assistants.copilot = { + ...result.assistants.copilot, + ...repo.assistants.copilot, + }; + } // Commands config if (repo.commands) { @@ -445,8 +396,6 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { * @returns Merged configuration with all overrides applied */ export async function loadConfig(repoPath?: string): Promise { - registerBuiltinProviders(); - // 1. Start with defaults let config = getDefaults(); @@ -505,10 +454,10 @@ export async function updateGlobalConfig(updates: Partial): Promis if (updates.defaultAssistant !== undefined) merged.defaultAssistant = updates.defaultAssistant; if (updates.assistants) { - merged.assistants = mergeAssistantDefaults( - mergeAssistantDefaults(getDefaults().assistants, current.assistants), - updates.assistants - ); + merged.assistants = { + claude: { ...current.assistants?.claude, ...updates.assistants.claude }, + codex: { ...current.assistants?.codex, ...updates.assistants.codex }, + }; } if (updates.streaming) { @@ -549,7 +498,19 @@ export function toSafeConfig(config: MergedConfig): SafeConfig { return { botName: config.botName, assistant: config.assistant, - assistants: toSafeAssistantDefaults(config.assistants), + assistants: { + claude: { + model: config.assistants.claude.model, + }, + codex: { + model: config.assistants.codex.model, + modelReasoningEffort: config.assistants.codex.modelReasoningEffort, + webSearchMode: config.assistants.codex.webSearchMode, + }, + copilot: { + model: config.assistants.copilot.model, + }, + }, streaming: { telegram: config.streaming.telegram, discord: config.streaming.discord, diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 135a4de3f5..a182454708 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -16,27 +16,10 @@ import type { ClaudeProviderDefaults, CodexProviderDefaults, - ProviderDefaultsMap, + CopilotProviderDefaults, } from '@archon/providers/types'; -export type { ClaudeProviderDefaults, CodexProviderDefaults, ProviderDefaultsMap }; - -/** - * Intersection type: generic ProviderDefaultsMap (any string key) with typed built-in entries. - * Built-in keys are typed so parseClaudeConfig/parseCodexConfig get type safety without casts. - * Community providers use the generic [string] index. This is intentional — removing the - * built-in intersection would force `as` casts everywhere built-in config is accessed. - */ -export type AssistantDefaultsConfig = ProviderDefaultsMap & { - claude?: ClaudeProviderDefaults; - codex?: CodexProviderDefaults; -}; - -/** Required variant — built-ins always present after config merge (registerBuiltinProviders guarantees it). */ -export type AssistantDefaults = ProviderDefaultsMap & { - claude: ClaudeProviderDefaults; - codex: CodexProviderDefaults; -}; +export type { ClaudeProviderDefaults, CodexProviderDefaults, CopilotProviderDefaults }; export interface GlobalConfig { /** @@ -49,12 +32,16 @@ export interface GlobalConfig { * Default AI assistant when no codebase-specific preference * @default 'claude' */ - defaultAssistant?: string; + defaultAssistant?: 'claude' | 'codex' | 'copilot'; /** * Assistant-specific defaults (model, reasoning effort, etc.) */ - assistants?: AssistantDefaultsConfig; + assistants?: { + claude?: ClaudeProviderDefaults; + codex?: CodexProviderDefaults; + copilot?: CopilotProviderDefaults; + }; /** * Platform streaming preferences (can be overridden per conversation) @@ -103,12 +90,16 @@ export interface RepoConfig { * AI assistant preference for this repository * Overrides global default */ - assistant?: string; + assistant?: 'claude' | 'codex' | 'copilot'; /** * Assistant-specific defaults for this repository */ - assistants?: AssistantDefaultsConfig; + assistants?: { + claude?: ClaudeProviderDefaults; + codex?: CodexProviderDefaults; + copilot?: CopilotProviderDefaults; + }; /** * Commands configuration @@ -197,8 +188,12 @@ export interface RepoConfig { */ export interface MergedConfig { botName: string; - assistant: string; - assistants: AssistantDefaults; + assistant: 'claude' | 'codex' | 'copilot'; + assistants: { + claude: ClaudeProviderDefaults; + codex: CodexProviderDefaults; + copilot: CopilotProviderDefaults; + }; streaming: { telegram: 'stream' | 'batch'; discord: 'stream' | 'batch'; @@ -250,8 +245,12 @@ export interface MergedConfig { */ export interface SafeConfig { botName: string; - assistant: string; - assistants: ProviderDefaultsMap; + assistant: 'claude' | 'codex' | 'copilot'; + assistants: { + claude: Pick; + codex: Pick; + copilot: Pick; + }; streaming: { telegram: 'stream' | 'batch'; discord: 'stream' | 'batch'; diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 8c38adc810..49195046cc 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -785,7 +785,11 @@ export async function handleMessage( } const requestOptions: SendQueryOptions = { - assistantConfig: config.assistants[providerKey] ?? {}, + assistantConfig: + (config.assistants[providerKey as 'claude' | 'codex' | 'copilot'] as Record< + string, + unknown + >) ?? {}, env: Object.keys(effectiveEnv).length > 0 ? effectiveEnv : undefined, }; diff --git a/packages/docs-web/src/content/docs/book/quick-reference.md b/packages/docs-web/src/content/docs/book/quick-reference.md index ae37659f7a..393d74d3a6 100644 --- a/packages/docs-web/src/content/docs/book/quick-reference.md +++ b/packages/docs-web/src/content/docs/book/quick-reference.md @@ -108,7 +108,11 @@ archon workflow run my-workflow "auth refresh-tokens" | `name` | Yes | string | Identifies the workflow in `archon workflow list` | | `description` | Yes | string | Shown in listings and used by the router | | `nodes` | Yes | array | DAG nodes (see Node Options below) | +<<<<<<< HEAD +| `provider` | No | `claude` \| `codex` \| `copilot` | AI provider for all nodes (defaults to the configured default assistant) | +======= | `provider` | No | string | Registered provider identifier (e.g. `claude`, `codex`). Default: `claude` | +>>>>>>> origin/dev | `model` | No | string | Model for all nodes (`sonnet`, `opus`, `haiku`, or full model ID) | | `modelReasoningEffort` | No | string | Codex only: `minimal` \| `low` \| `medium` \| `high` \| `xhigh` | | `webSearchMode` | No | string | Codex only: `disabled` \| `cached` \| `live` | @@ -128,7 +132,11 @@ All nodes share these base fields: | `depends_on` | No | string[] | Node IDs that must complete before this node runs | | `when` | No | string | Condition expression; node is skipped if false | | `trigger_rule` | No | string | Join semantics when multiple upstreams exist (see Trigger Rules) | +<<<<<<< HEAD +| `provider` | No | `claude` \| `codex` \| `copilot` | Per-node provider override | +======= | `provider` | No | string | Per-node provider override (any registered provider) | +>>>>>>> origin/dev | `model` | No | string | Per-node model override | | `context` | No | `fresh` \| `shared` | Session context — `fresh` starts a new conversation, `shared` inherits from prior node | | `output_format` | No | JSON Schema | Enforce structured JSON output from this node | diff --git a/packages/docs-web/src/content/docs/guides/authoring-workflows.md b/packages/docs-web/src/content/docs/guides/authoring-workflows.md index 3651ccae37..a75c186b1e 100644 --- a/packages/docs-web/src/content/docs/guides/authoring-workflows.md +++ b/packages/docs-web/src/content/docs/guides/authoring-workflows.md @@ -402,7 +402,7 @@ nodes: - `allowed_tools: []` disables all built-in tools (useful for MCP-only nodes). Use the `mcp` field on a node to attach per-node MCP servers — see [Node Fields](#node-fields) - If both are set, `denied_tools` is applied after `allowed_tools` - `undefined` (field absent) and `[]` have different semantics — absent means use default tool set, `[]` means no tools -- Claude only — Codex nodes/steps emit a warning and continue (Codex doesn't support per-call tool restrictions) +- Claude only — Codex and Copilot nodes emit a warning and continue (they don't support per-call tool restrictions) --- @@ -542,8 +542,13 @@ Model and options are resolved in this order: ```yaml name: my-workflow +<<<<<<< HEAD +provider: claude # 'claude', 'codex', or 'copilot' (default: from config) +model: sonnet # Model override (default: from config assistants..model) +======= provider: claude # Any registered provider (default: from config) model: sonnet # Model override (default: from config assistants.claude.model) +>>>>>>> origin/dev ``` **Claude models:** diff --git a/packages/docs-web/src/content/docs/guides/mcp-servers.md b/packages/docs-web/src/content/docs/guides/mcp-servers.md index 41f7f331cf..552af81b5f 100644 --- a/packages/docs-web/src/content/docs/guides/mcp-servers.md +++ b/packages/docs-web/src/content/docs/guides/mcp-servers.md @@ -360,8 +360,8 @@ bun run cli workflow run archon-smart-pr-review "Review PR #123" ## Limitations -- **Claude only** — Codex nodes warn and ignore the `mcp` field. Configure MCP - servers globally in the Codex CLI config instead. +- **Claude only** — Codex and Copilot nodes warn and ignore the `mcp` field. Configure MCP + servers globally in the Codex or Copilot CLI config instead. - **Haiku model** — Tool search (lazy loading for many tools) is not supported on Haiku. You'll see a warning. Consider using Sonnet or Opus for MCP nodes. - **No load-time validation** — The MCP config file is read at execution time, not diff --git a/packages/docs-web/src/content/docs/guides/skills.md b/packages/docs-web/src/content/docs/guides/skills.md index 8cfc5e5e81..9014967981 100644 --- a/packages/docs-web/src/content/docs/guides/skills.md +++ b/packages/docs-web/src/content/docs/guides/skills.md @@ -13,7 +13,7 @@ DAG workflow nodes support a `skills` field that preloads named skills into the node's agent context. Each node gets specialized procedural knowledge — code review patterns, Remotion best practices, testing conventions — without polluting other nodes. -**Claude only** — Codex nodes will warn and ignore the `skills` field. +**Claude only** — Codex and Copilot nodes will warn and ignore the `skills` field. ## Quick Start @@ -202,9 +202,9 @@ Skills and MCP compose naturally on the same node: Skills teach the **process**. MCP provides the **capability**. Together they produce better results than either alone. -## Codex Compatibility +## Codex and Copilot Compatibility -Codex nodes with `skills` log a warning and continue without the skills: +Codex and Copilot nodes with `skills` log a warning and continue without the skills: ``` Warning: Node 'review' has skills set but uses Codex — per-node skills @@ -229,7 +229,7 @@ To use skills, ensure the node uses Claude (the default provider, or set | Problem | Cause | Fix | |---------|-------|-----| | Skill not found | Not installed | Run `npx skills add ` | -| Skill ignored | Node uses Codex provider | Set `provider: claude` on the node | +| Skill ignored | Node uses Codex or Copilot provider | Set `provider: claude` on the node | | Too many skills | Context budget exceeded | Reduce to 2-3 most relevant skills per node | | Skill has no effect | Description too vague | Rewrite SKILL.md with specific, actionable instructions | diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index 900b8c0313..41bbc8850a 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -51,7 +51,11 @@ Create `~/.archon/config.yaml` for user-wide preferences: ```yaml # Default AI assistant +<<<<<<< HEAD +defaultAssistant: claude # or 'codex' or 'copilot' +======= defaultAssistant: claude # must match a registered provider (e.g. claude, codex) +>>>>>>> origin/dev # Assistant defaults assistants: @@ -177,7 +181,11 @@ Environment variables override all other configuration. They are organized by ca | `PORT` | HTTP server listen port | `3090` (auto-allocated in worktrees) | | `LOG_LEVEL` | Logging verbosity (`fatal`, `error`, `warn`, `info`, `debug`, `trace`) | `info` | | `BOT_DISPLAY_NAME` | Bot name shown in batch-mode "starting" messages | `Archon` | +<<<<<<< HEAD +| `DEFAULT_AI_ASSISTANT` | Default AI assistant (`claude`, `codex`, or `copilot`) | `claude` | +======= | `DEFAULT_AI_ASSISTANT` | Default AI assistant (must match a registered provider) | `claude` | +>>>>>>> origin/dev | `MAX_CONCURRENT_CONVERSATIONS` | Maximum concurrent AI conversations | `10` | | `SESSION_RETENTION_DAYS` | Delete inactive sessions older than N days | `30` | | `ARCHON_SUPPRESS_NESTED_CLAUDE_WARNING` | When set to `1`, suppresses the stderr warning emitted when `archon` is run inside a Claude Code session | -- | diff --git a/packages/providers/package.json b/packages/providers/package.json index cbe4a4617a..4fdeae4d31 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -12,6 +12,8 @@ "./codex/provider": "./src/codex/provider.ts", "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", + "./copilot/provider": "./src/copilot/provider.ts", + "./copilot/config": "./src/copilot/config.ts", "./errors": "./src/errors.ts", "./registry": "./src/registry.ts" }, @@ -22,6 +24,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/paths": "workspace:*", + "@github/copilot-sdk": "^0.2.2", "@openai/codex-sdk": "^0.116.0" }, "devDependencies": { diff --git a/packages/providers/src/copilot/capabilities.ts b/packages/providers/src/copilot/capabilities.ts new file mode 100644 index 0000000000..718a046bb5 --- /dev/null +++ b/packages/providers/src/copilot/capabilities.ts @@ -0,0 +1,16 @@ +import type { ProviderCapabilities } from '../types'; + +export const COPILOT_CAPABILITIES: ProviderCapabilities = { + sessionResume: true, + mcp: false, + hooks: false, + skills: false, + toolRestrictions: false, + structuredOutput: false, + envInjection: false, + costControl: false, + effortControl: true, + thinkingControl: true, + fallbackModel: false, + sandbox: false, +}; diff --git a/packages/providers/src/copilot/config.ts b/packages/providers/src/copilot/config.ts new file mode 100644 index 0000000000..6b08395592 --- /dev/null +++ b/packages/providers/src/copilot/config.ts @@ -0,0 +1,18 @@ +/** + * Copilot provider configuration parsing + */ +export interface CopilotProviderDefaults { + model?: string; +} + +export function parseCopilotConfig( + assistantConfig: Record +): CopilotProviderDefaults { + const config: CopilotProviderDefaults = {}; + + if (typeof assistantConfig.model === 'string') { + config.model = assistantConfig.model; + } + + return config; +} diff --git a/packages/providers/src/copilot/provider.ts b/packages/providers/src/copilot/provider.ts new file mode 100644 index 0000000000..8e9f2419c3 --- /dev/null +++ b/packages/providers/src/copilot/provider.ts @@ -0,0 +1,317 @@ +/** + * GitHub Copilot SDK wrapper + * Provides async generator interface for streaming Copilot responses + * + * The Copilot SDK delivers response content via sendAndWait()'s return value + * (AssistantMessageEvent), NOT through streaming delta events. Metadata events + * (usage, session state) ARE delivered via the event handler during sendAndWait. + * This provider subscribes to events for metadata, then yields the final content + * from sendAndWait's result. + */ +import { CopilotClient, approveAll } from '@github/copilot-sdk'; +import type { CopilotSession, SessionEvent } from '@github/copilot-sdk'; + +type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; +import type { + IAgentProvider, + SendQueryOptions, + MessageChunk, + TokenUsage, + ProviderCapabilities, +} from '../types'; +import { parseCopilotConfig } from './config'; +import { createLogger } from '@archon/paths'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.copilot'); + return cachedLog; +} + +let copilotClient: CopilotClient | null = null; + +function getCopilotClient(): CopilotClient { + if (!copilotClient) { + copilotClient = new CopilotClient(); + } + return copilotClient; +} + +function normalizeCopilotUsage(usage?: { + inputTokens?: number; + outputTokens?: number; +}): TokenUsage | undefined { + if (!usage) return undefined; + const input = usage.inputTokens; + const output = usage.outputTokens; + if (typeof input !== 'number' || typeof output !== 'number') return undefined; + return { + input, + output, + }; +} + +const UNSUPPORTED_OPTIONS = [ + 'tools', + 'disallowedTools', + 'outputFormat', + 'hooks', + 'mcpServers', + 'allowedTools', + 'agents', + 'agent', + 'settingSources', + 'env', + 'effort', + 'thinking', + 'maxBudgetUsd', + 'fallbackModel', + 'betas', + 'sandbox', + 'additionalDirectories', + 'webSearchMode', + 'idle_timeout', + 'mcp', + 'skills', +]; + +const UNSUPPORTED_BOOLEAN_OPTIONS = ['forkSession', 'persistSession']; + +export class CopilotProvider implements IAgentProvider { + getCapabilities(): ProviderCapabilities { + return { + sessionResume: true, + mcp: false, + hooks: false, + skills: false, + toolRestrictions: false, + structuredOutput: false, + envInjection: false, + costControl: false, + effortControl: true, + thinkingControl: true, + fallbackModel: false, + sandbox: false, + }; + } + + warnUnsupportedOptions(options: SendQueryOptions): void { + const nodeConfig = options?.nodeConfig; + for (const opt of UNSUPPORTED_OPTIONS) { + if (nodeConfig?.[opt] !== undefined) { + getLog().warn({ option: opt }, 'copilot.option_not_supported'); + } + } + + for (const opt of UNSUPPORTED_BOOLEAN_OPTIONS) { + if (nodeConfig?.[opt] !== undefined) { + getLog().warn({ option: opt, value: nodeConfig[opt] }, 'copilot.option_not_supported'); + } + } + + if (nodeConfig?.forkSession === true) { + throw new Error('forkSession is not supported by Copilot provider'); + } + if (nodeConfig?.persistSession === true) { + throw new Error('persistSession is not supported by Copilot provider'); + } + } + + async *sendQuery( + prompt: string, + cwd: string, + resumeSessionId?: string, + requestOptions?: SendQueryOptions + ): AsyncGenerator { + if (requestOptions) { + this.warnUnsupportedOptions(requestOptions); + } + + const assistantConfig = requestOptions?.assistantConfig ?? {}; + const copilotConfig = parseCopilotConfig(assistantConfig); + + const model = requestOptions?.model ?? copilotConfig.model; + const rawEffort = requestOptions?.nodeConfig?.effort ?? assistantConfig.modelReasoningEffort; + const modelReasoningEffort = + typeof rawEffort === 'string' ? (rawEffort as ReasoningEffort) : undefined; + + const client = getCopilotClient(); + + let session: CopilotSession; + + if (resumeSessionId) { + getLog().debug({ sessionId: resumeSessionId }, 'copilot.resuming_session'); + session = await client.resumeSession(resumeSessionId, { + workingDirectory: cwd, + model, + reasoningEffort: modelReasoningEffort, + onPermissionRequest: approveAll, + }); + } else { + getLog().debug({ cwd }, 'copilot.creating_session'); + session = await client.createSession({ + workingDirectory: cwd, + model, + reasoningEffort: modelReasoningEffort, + onPermissionRequest: approveAll, + }); + } + + getLog().info({ sessionId: session.sessionId }, 'copilot.session_created'); + + try { + // The Copilot SDK delivers response content via sendAndWait's return value + // rather than through streaming delta events. We subscribe to events for + // metadata (usage, errors) but yield content from the sendAndWait result. + const metadataEvents: SessionEvent[] = []; + const unsubscribe = session.on((event: SessionEvent) => { + metadataEvents.push(event); + }); + + let sendAndWaitResult; + try { + sendAndWaitResult = await session.sendAndWait({ prompt }); + } finally { + unsubscribe(); + } + + getLog().info( + { sessionId: session.sessionId, hasResult: !!sendAndWaitResult }, + 'copilot.sendAndWait_completed' + ); + + // Yield any usage events collected during the session + const toolCallIdToName = new Map(); + let usageTokens: TokenUsage | undefined; + for (const event of metadataEvents) { + switch (event.type) { + case 'assistant.reasoning': { + const reasoningEvent = event as { data: { content: string } }; + if (reasoningEvent.data.content) { + yield { type: 'thinking', content: reasoningEvent.data.content }; + } + break; + } + case 'assistant.reasoning_delta': { + const deltaEvent = event as { data: { deltaContent: string } }; + if (deltaEvent.data.deltaContent) { + yield { type: 'thinking', content: deltaEvent.data.deltaContent }; + } + break; + } + case 'assistant.message_delta': { + const deltaEvent = event as { data: { deltaContent: string } }; + if (deltaEvent.data.deltaContent) { + yield { type: 'assistant', content: deltaEvent.data.deltaContent }; + } + break; + } + case 'assistant.usage': { + const usageEvent = event as { + data?: { inputTokens?: number; outputTokens?: number }; + }; + if (usageEvent.data) { + const usage = normalizeCopilotUsage(usageEvent.data); + if (usage) { + // Defer result yield until after content — dag-executor breaks on result + usageTokens = usage; + } + } + break; + } + case 'tool.execution_start': { + const startEvent = event as { data: { toolCallId: string; toolName: string } }; + toolCallIdToName.set(startEvent.data.toolCallId, startEvent.data.toolName); + break; + } + case 'tool.execution_complete': { + const completeEvent = event as { + data: { + toolCallId: string; + success: boolean; + result?: { content: string; detailedContent?: string }; + }; + }; + const toolName = toolCallIdToName.get(completeEvent.data.toolCallId) ?? 'unknown'; + let output = ''; + if (completeEvent.data.result) { + output = + completeEvent.data.result.detailedContent ?? completeEvent.data.result.content; + } + if (!completeEvent.data.success) { + output = `❌ ${output}`; + } + yield { + type: 'tool_result', + toolName, + toolOutput: output, + toolCallId: completeEvent.data.toolCallId, + }; + break; + } + case 'session.error': { + const errorEvent = event as { data: { message: string } }; + getLog().error( + { sessionId: session.sessionId, error: errorEvent.data.message }, + 'copilot.session_error' + ); + break; + } + default: + getLog().debug( + { sessionId: session.sessionId, eventType: event.type }, + 'copilot.unhandled_event_type' + ); + break; + } + } + + // Yield content from sendAndWait result if we didn't get it from streaming deltas + if (sendAndWaitResult?.data?.content) { + const hadStreamingContent = metadataEvents.some( + e => + e.type === 'assistant.message_delta' && + (e as { data: { deltaContent: string } }).data?.deltaContent + ); + if (!hadStreamingContent) { + yield { type: 'assistant', content: sendAndWaitResult.data.content }; + } + } + + // Yield tool requests from sendAndWait result + if (sendAndWaitResult?.data?.toolRequests && sendAndWaitResult.data.toolRequests.length > 0) { + for (const tool of sendAndWaitResult.data.toolRequests) { + yield { + type: 'tool', + toolName: tool.name, + toolInput: tool.arguments ?? {}, + toolCallId: tool.toolCallId, + }; + } + } + + // Yield result LAST — dag-executor breaks on result type + if (usageTokens) { + yield { + type: 'result', + sessionId: session.sessionId, + tokens: usageTokens, + }; + } + + // If we got no content at all, log a warning + if ( + !sendAndWaitResult?.data?.content && + !metadataEvents.some(e => e.type === 'assistant.message_delta') + ) { + getLog().warn({ sessionId: session.sessionId }, 'copilot.no_content_received'); + } + } finally { + await session.disconnect(); + } + } + + getType(): string { + return 'copilot'; + } +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index e24bb630eb..94a3006707 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -35,10 +35,12 @@ export { UnknownProviderError } from './errors'; // Provider classes export { ClaudeProvider } from './claude/provider'; export { CodexProvider } from './codex/provider'; +export { CopilotProvider } from './copilot/provider'; // Config parsers export { parseClaudeConfig, type ClaudeProviderDefaults } from './claude/config'; export { parseCodexConfig, type CodexProviderDefaults } from './codex/config'; +export { parseCopilotConfig, type CopilotProviderDefaults } from './copilot/config'; // Utilities (needed by consumers) export { resetCodexSingleton } from './codex/provider'; diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 7af9dd21e7..757eee3d60 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -191,23 +191,24 @@ describe('registry', () => { describe('getRegisteredProviders', () => { test('returns all registered providers', () => { const all = getRegisteredProviders(); - expect(all.length).toBe(2); + expect(all.length).toBe(3); const ids = all.map(r => r.id); expect(ids).toContain('claude'); expect(ids).toContain('codex'); + expect(ids).toContain('copilot'); }); test('includes community providers after registration', () => { registerProvider(makeMockRegistration('my-llm')); const all = getRegisteredProviders(); - expect(all.length).toBe(3); + expect(all.length).toBe(4); }); }); describe('getProviderInfoList', () => { test('returns API-safe projection without factory', () => { const infos = getProviderInfoList(); - expect(infos.length).toBe(2); + expect(infos.length).toBe(3); for (const info of infos) { expect(info).toHaveProperty('id'); expect(info).toHaveProperty('displayName'); @@ -223,6 +224,7 @@ describe('registry', () => { test('returns true for registered providers', () => { expect(isRegisteredProvider('claude')).toBe(true); expect(isRegisteredProvider('codex')).toBe(true); + expect(isRegisteredProvider('copilot')).toBe(true); }); test('returns false for unknown providers', () => { @@ -236,7 +238,7 @@ describe('registry', () => { registerBuiltinProviders(); registerBuiltinProviders(); const all = getRegisteredProviders(); - expect(all.length).toBe(2); + expect(all.length).toBe(3); }); }); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 8c80d163b2..cf7bb77dd9 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -15,8 +15,10 @@ import type { } from './types'; import { ClaudeProvider } from './claude/provider'; import { CodexProvider } from './codex/provider'; +import { CopilotProvider } from './copilot/provider'; import { CLAUDE_CAPABILITIES } from './claude/capabilities'; import { CODEX_CAPABILITIES } from './codex/capabilities'; +import { COPILOT_CAPABILITIES } from './copilot/capabilities'; import { UnknownProviderError } from './errors'; import { createLogger } from '@archon/paths'; @@ -130,6 +132,14 @@ export function registerBuiltinProviders(): void { }, builtIn: true, }, + { + id: 'copilot', + displayName: 'GitHub Copilot', + factory: () => new CopilotProvider(), + capabilities: COPILOT_CAPABILITIES, + isModelCompatible: (): boolean => true, + builtIn: true, + }, ]; for (const entry of builtins) { diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 435073d745..f966b1b27a 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -7,7 +7,6 @@ // Single source of truth for provider-specific config shapes. export interface ClaudeProviderDefaults { - [key: string]: unknown; model?: string; /** Claude Code settingSources — controls which CLAUDE.md files are loaded. * @default ['project'] @@ -16,7 +15,6 @@ export interface ClaudeProviderDefaults { } export interface CodexProviderDefaults { - [key: string]: unknown; model?: string; /** Structurally matches @archon/workflows ModelReasoningEffort */ modelReasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; @@ -27,11 +25,48 @@ export interface CodexProviderDefaults { codexBinaryPath?: string; } -/** Generic per-provider defaults bag used by config surfaces and UI. */ -export type ProviderDefaults = Record; +export interface CopilotProviderDefaults { + model?: string; +} -/** Provider-keyed defaults map. Built-ins may refine individual entries. */ -export type ProviderDefaultsMap = Record; +/** + * Union type of all provider defaults. + */ +export type ProviderDefaults = + | ClaudeProviderDefaults + | CodexProviderDefaults + | CopilotProviderDefaults; + +/** + * Map of provider IDs to their default configurations. + */ +export interface ProviderDefaultsMap { + claude: ClaudeProviderDefaults; + codex: CodexProviderDefaults; + copilot: CopilotProviderDefaults; +} + +/** + * Provider registration entry for the registry. + */ +export interface ProviderRegistration { + id: string; + displayName: string; + factory: () => IAgentProvider; + capabilities: ProviderCapabilities; + isModelCompatible: (model: string) => boolean; + builtIn: boolean; +} + +/** + * Provider info exposed via API (excludes factory function). + */ +export interface ProviderInfo { + id: string; + displayName: string; + capabilities: ProviderCapabilities; + builtIn: boolean; +} /** * Token usage statistics from AI provider responses. @@ -154,46 +189,6 @@ export interface ProviderCapabilities { sandbox: boolean; } -/** - * Registration entry for a provider in the provider registry. - * Each entry carries metadata, a factory, and model-compatibility logic. - * The registry is the source of truth for provider identity, capabilities, and display. - */ -export interface ProviderRegistration { - /** Unique provider identifier — used in YAML, config, DB */ - id: string; - - /** Human-readable name for UI display */ - displayName: string; - - /** Instantiate a provider */ - factory: () => IAgentProvider; - - /** Static capability declaration — used for dag-executor warnings */ - capabilities: ProviderCapabilities; - - /** - * Model compatibility check. Returns true if the model string - * is valid for this provider. Used by workflow validation and - * provider inference from model names. - */ - isModelCompatible: (model: string) => boolean; - - /** Whether this is a built-in (maintained by core team) or community provider */ - builtIn: boolean; -} - -/** - * API-safe projection of ProviderRegistration (excludes non-serializable fields). - * Used by GET /api/providers and consumed by the Web UI. - */ -export interface ProviderInfo { - id: string; - displayName: string; - capabilities: ProviderCapabilities; - builtIn: boolean; -} - /** * Generic agent provider interface. * Allows supporting multiple agent providers (Claude, Codex, etc.) diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 1684a9b773..7a6dd12be8 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -2473,20 +2473,14 @@ export function registerApiRoutes( } updates.defaultAssistant = body.assistant; } - if (body.assistants !== undefined) { - const unknownProviders = Object.keys(body.assistants).filter( - id => !isRegisteredProvider(id) - ); - if (unknownProviders.length > 0) { - return apiError( - c, - 400, - `Unknown provider(s) in assistants: ${unknownProviders.join(', ')}. Available: ${getProviderInfoList() - .map(p => p.id) - .join(', ')}` - ); - } - updates.assistants = body.assistants; + if (body.claude !== undefined) { + updates.assistants = { ...updates.assistants, claude: body.claude }; + } + if (body.codex !== undefined) { + updates.assistants = { ...updates.assistants, codex: body.codex }; + } + if (body.copilot !== undefined) { + updates.assistants = { ...updates.assistants, copilot: body.copilot }; } await updateGlobalConfig(updates); diff --git a/packages/server/src/routes/schemas/config.schemas.ts b/packages/server/src/routes/schemas/config.schemas.ts index 06cd75ee3f..30cfee5653 100644 --- a/packages/server/src/routes/schemas/config.schemas.ts +++ b/packages/server/src/routes/schemas/config.schemas.ts @@ -4,13 +4,19 @@ import { z } from '@hono/zod-openapi'; /** Schema for the safe config subset returned to web clients (mirrors SafeConfig in config-types.ts). */ -const providerDefaultsSchema = z.record(z.string(), z.unknown()).openapi('ProviderDefaults'); - export const safeConfigSchema = z .object({ botName: z.string(), - assistant: z.string().min(1), - assistants: z.record(z.string(), providerDefaultsSchema), + assistant: z.enum(['claude', 'codex', 'copilot']), + assistants: z.object({ + claude: z.object({ model: z.string().optional() }), + codex: z.object({ + model: z.string().optional(), + modelReasoningEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']).optional(), + webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), + }), + copilot: z.object({ model: z.string().optional() }), + }), streaming: z.object({ telegram: z.enum(['stream', 'batch']), discord: z.enum(['stream', 'batch']), @@ -29,8 +35,24 @@ export const safeConfigSchema = z /** Body for PATCH /api/config/assistants — all fields optional (partial update). */ export const updateAssistantConfigBodySchema = z .object({ - assistant: z.string().min(1).optional(), - assistants: z.record(z.string(), providerDefaultsSchema).optional(), + assistant: z.enum(['claude', 'codex', 'copilot']).optional(), + claude: z + .object({ + model: z.string(), + }) + .optional(), + codex: z + .object({ + model: z.string(), + modelReasoningEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']).optional(), + webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), + }) + .optional(), + copilot: z + .object({ + model: z.string(), + }) + .optional(), }) .openapi('UpdateAssistantConfigBody'); diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index aef51bc764..2caf8f2b65 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -244,7 +244,7 @@ export function substituteNodeOutputRefs( */ async function resolveNodeProviderAndModel( node: DagNode, - workflowProvider: string, + workflowProvider: 'claude' | 'codex' | 'copilot', workflowModel: string | undefined, config: WorkflowConfig, platform: IWorkflowPlatform, @@ -253,18 +253,17 @@ async function resolveNodeProviderAndModel( _cwd: string, workflowLevelOptions: WorkflowLevelOptions ): Promise<{ - provider: string; + provider: 'claude' | 'codex' | 'copilot'; model: string | undefined; options: SendQueryOptions | undefined; }> { - const provider: string = node.provider ?? inferProviderFromModel(node.model, workflowProvider); + const provider: 'claude' | 'codex' | 'copilot' = + (node.provider as 'claude' | 'codex' | 'copilot') ?? + inferProviderFromModel(node.model, workflowProvider); const providerAssistantConfig = config.assistants[provider]; const model: string | undefined = - node.model ?? - (provider === workflowProvider - ? workflowModel - : (providerAssistantConfig?.model as string | undefined)); + node.model ?? (provider === workflowProvider ? workflowModel : providerAssistantConfig?.model); if (!isModelCompatible(provider, model)) { throw new Error( @@ -456,7 +455,7 @@ async function executeNodeInternal( cwd: string, workflowRun: WorkflowRun, node: CommandNode | PromptNode, - provider: string, + provider: 'claude' | 'codex' | 'copilot', nodeOptions: SendQueryOptions | undefined, artifactsDir: string, logDir: string, @@ -1408,7 +1407,7 @@ async function executeScriptNode( * Uses the same nodeConfig + assistantConfig pattern as resolveNodeProviderAndModel. */ function buildLoopNodeOptions( - provider: string, + provider: 'claude' | 'codex' | 'copilot', model: string | undefined, config: WorkflowConfig, workflowLevelOptions?: WorkflowLevelOptions @@ -1447,7 +1446,7 @@ async function executeLoopNode( cwd: string, workflowRun: WorkflowRun, node: LoopNode, - workflowProvider: string, + workflowProvider: 'claude' | 'codex' | 'copilot', workflowModel: string | undefined, artifactsDir: string, logDir: string, @@ -1943,7 +1942,7 @@ async function executeApprovalNode( deps: WorkflowDeps, platform: IWorkflowPlatform, conversationId: string, - workflowProvider: string, + workflowProvider: 'claude' | 'codex' | 'copilot', workflowModel: string | undefined, cwd: string, artifactsDir: string, @@ -2113,7 +2112,7 @@ export async function executeDagWorkflow( cwd: string, workflow: { name: string; nodes: readonly DagNode[] } & WorkflowLevelOptions, workflowRun: WorkflowRun, - workflowProvider: string, + workflowProvider: 'claude' | 'codex' | 'copilot', workflowModel: string | undefined, artifactsDir: string, logDir: string, @@ -2351,14 +2350,13 @@ export async function executeDagWorkflow( // 3b. Loop node dispatch — manages its own AI sessions and iteration if (isLoopNode(node)) { // Resolve per-node provider/model overrides (same logic as other node types) - const loopProvider: string = - node.provider ?? inferProviderFromModel(node.model, workflowProvider); + const loopProvider: 'claude' | 'codex' | 'copilot' = + (node.provider as 'claude' | 'codex' | 'copilot') ?? + inferProviderFromModel(node.model, workflowProvider); const loopAssistantConfig = config.assistants[loopProvider]; const loopModel: string | undefined = node.model ?? - (loopProvider === workflowProvider - ? workflowModel - : (loopAssistantConfig?.model as string | undefined)); + (loopProvider === workflowProvider ? workflowModel : loopAssistantConfig?.model); if (!isModelCompatible(loopProvider, loopModel)) { return { diff --git a/packages/workflows/src/deps.ts b/packages/workflows/src/deps.ts index e8fccfca41..589e615960 100644 --- a/packages/workflows/src/deps.ts +++ b/packages/workflows/src/deps.ts @@ -15,7 +15,6 @@ import type { TokenUsage, SendQueryOptions, NodeConfig, - ProviderDefaultsMap, ProviderCapabilities, } from '@archon/providers/types'; @@ -26,7 +25,6 @@ export type { TokenUsage, SendQueryOptions, NodeConfig, - ProviderDefaultsMap, ProviderCapabilities, }; @@ -70,8 +68,8 @@ export interface IWorkflowPlatform { // --------------------------------------------------------------------------- export interface WorkflowConfig { - /** Default assistant provider (validated against provider registry at runtime) */ - assistant: string; + /** Default assistant provider ('claude' | 'codex' | 'copilot') */ + assistant: 'claude' | 'codex' | 'copilot'; baseBranch?: string; docsPath?: string; envVars?: Record; @@ -80,11 +78,7 @@ export interface WorkflowConfig { loadDefaultWorkflows?: boolean; loadDefaultCommands?: boolean; }; - // Intersection: generic map for community providers + typed built-in entries. - // Built-ins are typed so executor/dag-executor get type-safe config access for - // Claude settingSources, Codex reasoningEffort, etc. without casts. - // Community providers use the generic [string] index signature. - assistants: ProviderDefaultsMap & { + assistants: { claude: { model?: string; settingSources?: ('project' | 'user')[]; @@ -95,6 +89,9 @@ export interface WorkflowConfig { webSearchMode?: WebSearchMode; additionalDirectories?: string[]; }; + copilot: { + model?: string; + }; }; } @@ -102,7 +99,7 @@ export interface WorkflowConfig { // Agent provider factory type // --------------------------------------------------------------------------- -export type AgentProviderFactory = (provider: string) => IAgentProvider; +export type AgentProviderFactory = (provider: 'claude' | 'codex' | 'copilot') => IAgentProvider; // --------------------------------------------------------------------------- // WorkflowDeps — the single injection point diff --git a/packages/workflows/src/executor.ts b/packages/workflows/src/executor.ts index dbb15495d8..b63a273e05 100644 --- a/packages/workflows/src/executor.ts +++ b/packages/workflows/src/executor.ts @@ -278,10 +278,10 @@ export async function executeWorkflow( // Resolve provider and model once (used by all nodes) // When workflow sets a model but not a provider, infer provider from the model. // e.g. model: sonnet → provider: claude, even if config.assistant is codex. - let resolvedProvider: string; + let resolvedProvider: 'claude' | 'codex' | 'copilot'; let providerSource: string; if (workflow.provider) { - resolvedProvider = workflow.provider; + resolvedProvider = workflow.provider as 'claude' | 'codex' | 'copilot'; providerSource = 'workflow definition'; } else if (workflow.model) { resolvedProvider = inferProviderFromModel(workflow.model, config.assistant); @@ -290,8 +290,7 @@ export async function executeWorkflow( resolvedProvider = config.assistant; providerSource = 'config'; } - const assistantDefaults = config.assistants[resolvedProvider]; - const resolvedModel = workflow.model ?? (assistantDefaults?.model as string | undefined); + const resolvedModel = workflow.model ?? config.assistants[resolvedProvider]?.model; if (!isModelCompatible(resolvedProvider, resolvedModel)) { throw new Error( `Model "${resolvedModel}" is not compatible with provider "${resolvedProvider}". ` + diff --git a/packages/workflows/src/loader.test.ts b/packages/workflows/src/loader.test.ts index 79a72ba253..d54d1746b0 100644 --- a/packages/workflows/src/loader.test.ts +++ b/packages/workflows/src/loader.test.ts @@ -28,11 +28,6 @@ mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), })); -// Bootstrap provider registry (needed by isModelCompatible in dag-node schema) -import { registerBuiltinProviders, clearRegistry } from '@archon/providers'; -clearRegistry(); -registerBuiltinProviders(); - import { discoverWorkflows } from './workflow-discovery'; import { isBashNode, isCancelNode, isLoopNode } from './schemas'; import * as bundledDefaults from './defaults/bundled-defaults'; @@ -195,7 +190,7 @@ nodes: expect(workflows[0].provider).toBeUndefined(); }); - it('should treat invalid provider as undefined (executor handles fallback)', async () => { + it('should reject invalid provider at load time', async () => { const workflowDir = join(testDir, '.archon', 'workflows'); await mkdir(workflowDir, { recursive: true }); @@ -210,10 +205,12 @@ nodes: const result = await discoverWorkflows(testDir, { loadDefaults: false }); const workflows = result.workflows.map(ws => ws.workflow); + const errors = result.errors; - // Unknown providers are accepted (validated against registry at execution time) - expect(workflows).toHaveLength(1); - expect(workflows[0].provider).toBe('invalid'); + // Invalid provider is rejected at load time with a validation error + expect(workflows).toHaveLength(0); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].error).toContain('is not supported'); }); it('should reject claude model with codex provider at load time', async () => { diff --git a/packages/workflows/src/loader.ts b/packages/workflows/src/loader.ts index f9c21a9fcd..aa5c74bdf4 100644 --- a/packages/workflows/src/loader.ts +++ b/packages/workflows/src/loader.ts @@ -270,8 +270,25 @@ export function parseWorkflow(content: string, filename: string): ParseResult { // Parse workflow-level fields using WorkflowBaseSchema for validation // Note: modelReasoningEffort and webSearchMode use warn-and-ignore for invalid values // (consistent with original behavior) rather than schema-level rejection. - const provider = - typeof raw.provider === 'string' && raw.provider.length > 0 ? raw.provider : undefined; + const validProviders = ['claude', 'codex', 'copilot'] as const; + type ValidProvider = (typeof validProviders)[number]; + + // First validate: fail fast for unsupported providers + const rawProvider = raw.provider; + if (rawProvider !== undefined && !validProviders.includes(rawProvider as ValidProvider)) { + const providerVal = rawProvider as string; + return { + workflow: null, + error: { + filename, + error: `Provider "${providerVal}" is not supported. Use one of: ${validProviders.join(', ')}`, + errorType: 'validation_error', + }, + }; + } + + // Now we know raw.provider is valid (or undefined) + const provider: ValidProvider | undefined = raw.provider as ValidProvider | undefined; const model = typeof raw.model === 'string' ? raw.model : undefined; // Validate model/provider compatibility at workflow level diff --git a/packages/workflows/src/model-validation.test.ts b/packages/workflows/src/model-validation.test.ts index 2247fd7c05..446a90ffbd 100644 --- a/packages/workflows/src/model-validation.test.ts +++ b/packages/workflows/src/model-validation.test.ts @@ -45,6 +45,13 @@ describe('model-validation (registry-driven)', () => { // Empty string is falsy, so treated as "no model specified" expect(isModelCompatible('claude', '')).toBe(true); expect(isModelCompatible('codex', '')).toBe(true); + expect(isModelCompatible('copilot', '')).toBe(true); + }); + + it('should accept any model for copilot (copilot accepts all)', () => { + expect(isModelCompatible('copilot', 'any-model')).toBe(true); + expect(isModelCompatible('copilot', 'sonnet')).toBe(true); + expect(isModelCompatible('copilot', 'gpt-4')).toBe(true); }); it('should throw on unknown providers (fail-fast)', () => { @@ -71,10 +78,10 @@ describe('model-validation (registry-driven)', () => { expect(inferProviderFromModel('claude-opus-4-6', 'codex')).toBe('claude'); }); - it('should infer codex from non-Claude model names', () => { - expect(inferProviderFromModel('gpt-5.3-codex', 'claude')).toBe('codex'); - expect(inferProviderFromModel('gpt-4', 'claude')).toBe('codex'); - expect(inferProviderFromModel('o1-mini', 'claude')).toBe('codex'); + it('should return default provider for non-Claude model names', () => { + expect(inferProviderFromModel('gpt-5.3-codex', 'claude')).toBe('claude'); + expect(inferProviderFromModel('gpt-4', 'copilot')).toBe('copilot'); + expect(inferProviderFromModel('o1-mini', 'codex')).toBe('codex'); }); }); }); diff --git a/packages/workflows/src/model-validation.ts b/packages/workflows/src/model-validation.ts index 0140defce5..db22dfe136 100644 --- a/packages/workflows/src/model-validation.ts +++ b/packages/workflows/src/model-validation.ts @@ -1,41 +1,41 @@ -/** - * Registry-driven model validation. - * - * All provider/model compatibility checks delegate to ProviderRegistration entries - * in the provider registry. No hardcoded provider knowledge lives here. - */ -import { getRegistration, getRegisteredProviders, isRegisteredProvider } from '@archon/providers'; +export function isClaudeModel(model: string): boolean { + return ( + model === 'sonnet' || + model === 'opus' || + model === 'haiku' || + model === 'inherit' || + model.startsWith('claude-') + ); +} /** - * Infer provider from a model name by iterating BUILT-IN registrations only. - * Community providers must be selected explicitly via `provider:` in YAML. + * Infer provider from a model name. Returns 'claude' if the model matches + * Claude naming patterns, 'codex' otherwise. + * + * When no model is provided, returns the default provider. * - * Returns undefined if no built-in provider matches (caller falls back to config default). + * Phase 2 will replace this with a registry-driven lookup that iterates + * built-in provider registrations. */ -export function inferProviderFromModel(model: string | undefined, defaultProvider: string): string { +export function inferProviderFromModel( + model: string | undefined, + defaultProvider: 'claude' | 'codex' | 'copilot' +): 'claude' | 'codex' | 'copilot' { if (!model) return defaultProvider; - - for (const reg of getRegisteredProviders()) { - if (reg.builtIn && reg.isModelCompatible(model)) return reg.id; - } - - // No built-in matched — fall back to default + if (isClaudeModel(model)) return 'claude'; return defaultProvider; } -/** - * Check if a model is compatible with a provider using the registry. - * Returns true if no model is specified (any provider accepts no-model). - * Throws on unknown providers (fail-fast — matches getProviderCapabilities behavior). - */ -export function isModelCompatible(provider: string, model?: string): boolean { - if (!model) return true; - if (!isRegisteredProvider(provider)) { - throw new Error( - `Unknown provider '${provider}'. Registered providers: ${getRegisteredProviders() - .map(p => p.id) - .join(', ')}` - ); +export function isModelCompatible( + provider: 'claude' | 'codex' | 'copilot', + model?: string +): boolean { + // Validate provider is one of the allowed values + if (!['claude', 'codex', 'copilot'].includes(provider)) { + throw new Error(`Unknown provider '${provider}'`); } - return getRegistration(provider).isModelCompatible(model); + if (!model) return true; + if (provider === 'claude') return isClaudeModel(model); + if (provider === 'copilot') return true; + return !isClaudeModel(model); } diff --git a/packages/workflows/src/schemas/dag-node.ts b/packages/workflows/src/schemas/dag-node.ts index bac3368d30..19796a22ae 100644 --- a/packages/workflows/src/schemas/dag-node.ts +++ b/packages/workflows/src/schemas/dag-node.ts @@ -489,7 +489,7 @@ export const dagNodeSchema = dagNodeBaseSchema // Provider/model compatibility (AI nodes only) if (!hasBash && !hasLoop && !hasScript && data.provider && data.model) { try { - if (!isModelCompatible(data.provider, data.model)) { + if (!isModelCompatible(data.provider as 'claude' | 'codex' | 'copilot', data.model)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `model "${data.model}" is not compatible with provider "${data.provider}"`,