diff --git a/apps/agent-worker/src/runtime/agent-runtime.ts b/apps/agent-worker/src/runtime/agent-runtime.ts new file mode 100644 index 0000000..039dab9 --- /dev/null +++ b/apps/agent-worker/src/runtime/agent-runtime.ts @@ -0,0 +1,49 @@ +import type { NormalizedAgentEvent } from '../agent-events'; +import type { streamExecInContainer } from './docker'; +import type { AdapterWorkspace } from './provider'; + +export type AgentRuntimeName = 'codex' | 'opencode' | 'claude'; + +export type ExecStreamFn = typeof streamExecInContainer; + +export type TurnResult = { + // Assistant text the adapter captured out-of-band (Codex --output-last-message + // file, Claude `result` event). Empty/undefined when everything streamed via onEvent. + finalMessage?: string; + // Runtime session id to persist for continuity (resume/--session/--resume). + sessionId?: string; + // Non-empty when the runtime reported a hard failure the caller must surface. + error?: string; +}; + +export type AgentRuntime = { + readonly name: AgentRuntimeName; + prepareAuth(workspace: AdapterWorkspace): Promise; + runTurn( + workspace: AdapterWorkspace, + prompt: string, + onEvent: (event: NormalizedAgentEvent) => Promise, + ): Promise; + abort(workspace: AdapterWorkspace): Promise; +}; + +export type AdapterFactory = (deps?: { + execStream?: ExecStreamFn; +}) => AgentRuntime; + +const factories = new Map(); + +export const registerAdapter = ( + name: AgentRuntimeName, + factory: AdapterFactory, +): void => { + factories.set(name, factory); +}; + +export const getAdapter = (name: AgentRuntimeName): AgentRuntime => { + const factory = factories.get(name); + if (!factory) { + throw new Error(`No agent runtime adapter registered for "${name}".`); + } + return factory(); +}; diff --git a/apps/agent-worker/src/runtime/provider.ts b/apps/agent-worker/src/runtime/provider.ts new file mode 100644 index 0000000..37580df --- /dev/null +++ b/apps/agent-worker/src/runtime/provider.ts @@ -0,0 +1,171 @@ +import path from 'node:path'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; + +import type { AgentRuntimeName } from './agent-runtime'; + +export type Claim = { + job: { + _id: Id<'agentJobs'>; + prompt: string; + runtime?: 'codex' | 'opencode' | 'claude'; + jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution'; + envFilePath?: string; + materializeEnvFile?: boolean; + baseBranch: string; + workBranch: string; + forkOwner: string; + forkRepo: string; + upstreamOwner: string; + upstreamRepo: string; + }; + spoon: { name: string }; + openai: { + apiKey?: string; + model: string; + reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + }; + aiProviderProfile?: { + id: string; + name: string; + provider: + | 'openai' + | 'anthropic' + | 'google' + | 'openrouter' + | 'requesty' + | 'litellm' + | 'cloudflare_ai_gateway' + | 'custom_openai_compatible' + | 'opencode_openai_login'; + authType: 'api_key' | 'opencode_auth_json' | 'none'; + secret?: string; + baseUrl?: string; + model: string; + reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + }; + github: { installationId?: string }; + agentSettings?: { + installCommand?: string; + checkCommand?: string; + testCommand?: string; + autoDetectCommands?: boolean; + } | null; + secrets: { name: string; value: string }[]; +}; + +// The subset of the worker's active workspace that agent adapters read. +export type AdapterWorkspace = { + claim: Claim; + workdir: string; + homeDir: string; + username: string; + containerHome: string; + containerRepo: string; + repoDir: string; + boxName: string; + redact: (value: string) => string; + runtime: AgentRuntimeName; + codexSessionId?: string; + opencodeSessionId?: string; + claudeSessionId?: string; + turnMarker?: string; +}; + +export const isCodexLoginProfile = (claim: Claim) => + claim.aiProviderProfile?.provider === 'opencode_openai_login' || + claim.aiProviderProfile?.authType === 'opencode_auth_json'; + +export const collectJsonStringValues = (value?: string): string[] => { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + const values: string[] = []; + const visit = (item: unknown) => { + if (typeof item === 'string') { + if (item.length >= 12) values.push(item); + return; + } + if (Array.isArray(item)) { + item.forEach(visit); + return; + } + if (item && typeof item === 'object') { + Object.values(item).forEach(visit); + } + }; + visit(parsed); + return values; + } catch { + return []; + } +}; + +export const providerEnvironment = ( + claim: Claim, + workspaceRoot?: string, +): Record => { + if (isCodexLoginProfile(claim)) { + if (!workspaceRoot) { + throw new Error('Codex auth profiles require a prepared workspace.'); + } + return { + CODEX_HOME: path.join(workspaceRoot, '.codex'), + HOME: workspaceRoot, + XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'), + XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'), + }; + } + const profile = claim.aiProviderProfile; + const secret = profile?.secret ?? claim.openai.apiKey; + if (!secret) { + throw new Error('No AI provider credential is configured for this job.'); + } + const baseUrl: Record = profile?.baseUrl + ? { OPENAI_BASE_URL: profile.baseUrl } + : {}; + if (!profile || profile.provider === 'openai') { + return { OPENAI_API_KEY: secret, ...baseUrl }; + } + if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret }; + if (profile.provider === 'google') return { GOOGLE_API_KEY: secret }; + if (profile.provider === 'openrouter') { + return { OPENROUTER_API_KEY: secret, ...baseUrl }; + } + if (profile.provider === 'requesty') { + return { REQUESTY_API_KEY: secret, ...baseUrl }; + } + if (profile.provider === 'cloudflare_ai_gateway') { + return { CLOUDFLARE_API_KEY: secret, ...baseUrl }; + } + if ( + profile.provider === 'litellm' || + profile.provider === 'custom_openai_compatible' + ) { + return { OPENAI_API_KEY: secret, ...baseUrl }; + } + throw new Error('Unsupported AI provider profile.'); +}; + +export const opencodeModel = (claim: Claim) => { + const profile = claim.aiProviderProfile; + const model = profile?.model ?? claim.openai.model; + if (model.includes('/')) return model; + if (!profile) return `openai/${model}`; + if ( + profile.provider === 'custom_openai_compatible' || + profile.provider === 'cloudflare_ai_gateway' + ) { + return model; + } + if (profile.provider === 'opencode_openai_login') return `openai/${model}`; + return `${profile.provider}/${model}`; +}; + +export const codexModel = (claim: Claim) => { + const model = claim.aiProviderProfile?.model ?? claim.openai.model; + return model.includes('/') ? (model.split('/').at(-1) ?? model) : model; +}; + +export const codexModelArgs = (claim: Claim) => + isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)]; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 4f3b572..4ee61b8 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -16,6 +16,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js'; import type { NormalizedAgentEvent } from './agent-events'; import type { OpenCodeSession } from './opencode-session'; +import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { BoxHandle } from './user-container'; import { normalizeCodexJsonLine } from './agent-events'; import { abortManagedCodexProcess, managedCodexCommand } from './codex-process'; @@ -45,82 +46,26 @@ import { stopWorkspaceContainer, streamExecInContainer, } from './runtime/docker'; +import { + codexModelArgs, + collectJsonStringValues, + isCodexLoginProfile, + opencodeModel, + providerEnvironment, +} from './runtime/provider'; import { acquireUserBox, runningBoxUsernames } from './user-container'; import { fetchUserEnvironment, materializeUserHome } from './user-environment'; import { teardownWorkspace } from './workspace-teardown'; -type Claim = { - job: { - _id: Id<'agentJobs'>; - prompt: string; - runtime?: 'openai_direct' | 'opencode'; - jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution'; - envFilePath?: string; - materializeEnvFile?: boolean; - baseBranch: string; - workBranch: string; - forkOwner: string; - forkRepo: string; - upstreamOwner: string; - upstreamRepo: string; - }; - spoon: { name: string }; - openai: { - apiKey?: string; - model: string; - reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; - }; - aiProviderProfile?: { - id: string; - name: string; - provider: - | 'openai' - | 'anthropic' - | 'google' - | 'openrouter' - | 'requesty' - | 'litellm' - | 'cloudflare_ai_gateway' - | 'custom_openai_compatible' - | 'opencode_openai_login'; - authType: 'api_key' | 'opencode_auth_json' | 'none'; - secret?: string; - baseUrl?: string; - model: string; - reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; - }; - github: { installationId?: string }; - agentSettings?: { - installCommand?: string; - checkCommand?: string; - testCommand?: string; - autoDetectCommands?: boolean; - } | null; - secrets: { name: string; value: string }[]; -}; - -type ActiveWorkspace = { - claim: Claim; - // Host path of the persistent per-user home (mounted at `containerHome`). - // Equal to `homeDir`; kept as `workdir` for the container mount source. - workdir: string; - homeDir: string; - username: string; - // In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}). - containerHome: string; - containerRepo: string; - repoDir: string; +type ActiveWorkspace = AdapterWorkspace & { // Phase 2: the per-user box container this thread execs into. - boxName: string; boxHandle: BoxHandle; githubToken: string; - redact: (value: string) => string; runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli'; containerName?: string; containerId?: string; opencodePassword?: string; opencodeSession?: OpenCodeSession; - codexSessionId?: string; codexPidFile?: string; agentTurnActive?: boolean; cancelRequested?: boolean; @@ -381,104 +326,6 @@ const commandToShell = (command: string) => ['bash', '-lc', command]; const workspaceContainerName = (jobId: string) => `spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`; -const isCodexLoginProfile = (claim: Claim) => - claim.aiProviderProfile?.provider === 'opencode_openai_login' || - claim.aiProviderProfile?.authType === 'opencode_auth_json'; - -const collectJsonStringValues = (value?: string): string[] => { - if (!value) return []; - try { - const parsed = JSON.parse(value) as unknown; - const values: string[] = []; - const visit = (item: unknown) => { - if (typeof item === 'string') { - if (item.length >= 12) values.push(item); - return; - } - if (Array.isArray(item)) { - item.forEach(visit); - return; - } - if (item && typeof item === 'object') { - Object.values(item).forEach(visit); - } - }; - visit(parsed); - return values; - } catch { - return []; - } -}; - -const providerEnvironment = ( - claim: Claim, - workspaceRoot?: string, -): Record => { - if (isCodexLoginProfile(claim)) { - if (!workspaceRoot) { - throw new Error('Codex auth profiles require a prepared workspace.'); - } - return { - CODEX_HOME: path.join(workspaceRoot, '.codex'), - HOME: workspaceRoot, - XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'), - XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'), - }; - } - const profile = claim.aiProviderProfile; - const secret = profile?.secret ?? claim.openai.apiKey; - if (!secret) { - throw new Error('No AI provider credential is configured for this job.'); - } - const baseUrl: Record = profile?.baseUrl - ? { OPENAI_BASE_URL: profile.baseUrl } - : {}; - if (!profile || profile.provider === 'openai') { - return { OPENAI_API_KEY: secret, ...baseUrl }; - } - if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret }; - if (profile.provider === 'google') return { GOOGLE_API_KEY: secret }; - if (profile.provider === 'openrouter') { - return { OPENROUTER_API_KEY: secret, ...baseUrl }; - } - if (profile.provider === 'requesty') { - return { REQUESTY_API_KEY: secret, ...baseUrl }; - } - if (profile.provider === 'cloudflare_ai_gateway') { - return { CLOUDFLARE_API_KEY: secret, ...baseUrl }; - } - if ( - profile.provider === 'litellm' || - profile.provider === 'custom_openai_compatible' - ) { - return { OPENAI_API_KEY: secret, ...baseUrl }; - } - throw new Error('Unsupported AI provider profile.'); -}; - -const opencodeModel = (claim: Claim) => { - const profile = claim.aiProviderProfile; - const model = profile?.model ?? claim.openai.model; - if (model.includes('/')) return model; - if (!profile) return `openai/${model}`; - if ( - profile.provider === 'custom_openai_compatible' || - profile.provider === 'cloudflare_ai_gateway' - ) { - return model; - } - if (profile.provider === 'opencode_openai_login') return `openai/${model}`; - return `${profile.provider}/${model}`; -}; - -const codexModel = (claim: Claim) => { - const model = claim.aiProviderProfile?.model ?? claim.openai.model; - return model.includes('/') ? (model.split('/').at(-1) ?? model) : model; -}; - -const codexModelArgs = (claim: Claim) => - isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)]; - const writeJsonFile = async (filePath: string, content: string) => { let normalized = content.trim(); try { @@ -1432,6 +1279,8 @@ const runClaim = async (claim: Claim) => { boxHandle, githubToken, redact, + // Placeholder until Task 3 resolves the runtime from the claim/profile. + runtime: 'codex', }; if (userEnv) { await appendEvent( @@ -2167,7 +2016,10 @@ export const startWorker = async () => { await sleep(env.pollMs); continue; } - await runClaim(claim); + // The Convex claim still carries the legacy `openai_direct` runtime + // literal, which `runClaim` rejects. Narrow to the shared `Claim` shape + // (widened to the codex/opencode/claude union) at this boundary. + await runClaim(claim as Claim); } catch (error) { console.error(error); await sleep(env.pollMs); diff --git a/apps/agent-worker/tests/unit/agent-runtime.test.ts b/apps/agent-worker/tests/unit/agent-runtime.test.ts new file mode 100644 index 0000000..af5b972 --- /dev/null +++ b/apps/agent-worker/tests/unit/agent-runtime.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest'; + +import type { AgentRuntime } from '../../src/runtime/agent-runtime'; +import type { Claim } from '../../src/runtime/provider'; +import { getAdapter, registerAdapter } from '../../src/runtime/agent-runtime'; +import { isCodexLoginProfile, opencodeModel } from '../../src/runtime/provider'; + +describe('agent runtime registry', () => { + test('registerAdapter/getAdapter resolves a registered runtime', () => { + const fake: AgentRuntime = { + name: 'codex', + prepareAuth: async () => {}, + runTurn: async () => ({}), + abort: async () => {}, + }; + registerAdapter('codex', () => fake); + expect(getAdapter('codex').name).toBe('codex'); + }); + + test('getAdapter throws for an unregistered runtime', () => { + expect(() => getAdapter('opencode')).toThrow(/No agent runtime adapter/); + }); +}); + +describe('shared provider helpers re-exported from runtime/provider', () => { + test('isCodexLoginProfile detects opencode login profiles', () => { + expect( + isCodexLoginProfile({ + aiProviderProfile: { provider: 'opencode_openai_login' }, + } as Claim), + ).toBe(true); + expect(isCodexLoginProfile({} as Claim)).toBe(false); + }); + + test('opencodeModel prefixes the provider', () => { + expect( + opencodeModel({ + aiProviderProfile: { provider: 'anthropic', model: 'claude-x' }, + } as Claim), + ).toBe('anthropic/claude-x'); + }); +}); diff --git a/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts b/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts index 5e7c6b4..f415bd0 100644 --- a/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts +++ b/apps/agent-worker/tests/unit/maintenance-workspace-cleanup.test.ts @@ -156,6 +156,7 @@ describe('maintenance workspace cleanup', () => { boxHandle: { boxName: 'spoon-box-tester', release }, githubToken: 'github-token', redact: (value: string) => value, + runtime: 'codex', containerName: 'spoon-agent-job-maintenance', opencodeSession: { client: {} as never,