refactor(worker): extract AgentRuntime interface, shared provider helpers, and adapter registry
This commit is contained in:
@@ -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<void>;
|
||||
runTurn(
|
||||
workspace: AdapterWorkspace,
|
||||
prompt: string,
|
||||
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||
): Promise<TurnResult>;
|
||||
abort(workspace: AdapterWorkspace): Promise<void>;
|
||||
};
|
||||
|
||||
export type AdapterFactory = (deps?: {
|
||||
execStream?: ExecStreamFn;
|
||||
}) => AgentRuntime;
|
||||
|
||||
const factories = new Map<AgentRuntimeName, AdapterFactory>();
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -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<string, string> => {
|
||||
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<string, string> = 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)];
|
||||
Reference in New Issue
Block a user