* refactor: provider/modelの解決ロジックをAgentRunnerに集約 OptionsBuilderでCLIレベルとstepレベルを事前マージしていたのをやめ、 stepProvider/stepModelとして分離して渡す形に変更。 AgentRunnerが全レイヤーの優先度を一括で解決する。 * takt: takt-list
215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
/**
|
|
* Agent execution runners
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { basename, dirname } from 'node:path';
|
|
import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js';
|
|
import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
|
|
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
|
|
import { createLogger } from '../shared/utils/index.js';
|
|
import { loadTemplate } from '../shared/prompts/index.js';
|
|
import type { RunAgentOptions } from './types.js';
|
|
|
|
export type { RunAgentOptions, StreamCallback } from './types.js';
|
|
|
|
const log = createLogger('runner');
|
|
|
|
/**
|
|
* Agent execution runner.
|
|
*
|
|
* Resolves agent configuration (provider, model, prompt) and
|
|
* delegates execution to the appropriate provider.
|
|
*/
|
|
export class AgentRunner {
|
|
/** Resolve provider type from options, agent config, project config, global config */
|
|
private static resolveProvider(
|
|
cwd: string,
|
|
options?: RunAgentOptions,
|
|
agentConfig?: CustomAgentConfig,
|
|
): ProviderType {
|
|
if (options?.provider) return options.provider;
|
|
const projectConfig = loadProjectConfig(cwd);
|
|
if (projectConfig.provider) return projectConfig.provider;
|
|
if (options?.stepProvider) return options.stepProvider;
|
|
if (agentConfig?.provider) return agentConfig.provider;
|
|
try {
|
|
const globalConfig = loadGlobalConfig();
|
|
if (globalConfig.provider) return globalConfig.provider;
|
|
} catch (error) {
|
|
log.debug('Global config not available for provider resolution', { error });
|
|
}
|
|
return 'claude';
|
|
}
|
|
|
|
/**
|
|
* Resolve model from options, agent config, global config.
|
|
* Global config model is only used when its provider matches the resolved provider,
|
|
* preventing cross-provider model mismatches (e.g., 'opus' sent to Codex).
|
|
*/
|
|
private static resolveModel(
|
|
resolvedProvider: ProviderType,
|
|
options?: RunAgentOptions,
|
|
agentConfig?: CustomAgentConfig,
|
|
): string | undefined {
|
|
if (options?.model) return options.model;
|
|
if (options?.stepModel) return options.stepModel;
|
|
if (agentConfig?.model) return agentConfig.model;
|
|
try {
|
|
const globalConfig = loadGlobalConfig();
|
|
if (globalConfig.model) {
|
|
const globalProvider = globalConfig.provider ?? 'claude';
|
|
if (globalProvider === resolvedProvider) return globalConfig.model;
|
|
}
|
|
} catch (error) {
|
|
log.debug('Global config not available for model resolution', { error });
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Load persona prompt from file path */
|
|
private static loadPersonaPromptFromPath(personaPath: string): string {
|
|
if (!existsSync(personaPath)) {
|
|
throw new Error(`Persona file not found: ${personaPath}`);
|
|
}
|
|
return readFileSync(personaPath, 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Get persona name from path or spec.
|
|
* For personas in subdirectories, includes parent dir for pattern matching.
|
|
*/
|
|
private static extractPersonaName(personaSpec: string): string {
|
|
if (!personaSpec.endsWith('.md')) {
|
|
return personaSpec;
|
|
}
|
|
|
|
const name = basename(personaSpec, '.md');
|
|
const dir = basename(dirname(personaSpec));
|
|
|
|
if (dir === 'personas' || dir === '.') {
|
|
return name;
|
|
}
|
|
|
|
return `${dir}/${name}`;
|
|
}
|
|
|
|
/** Build ProviderCallOptions from RunAgentOptions */
|
|
private static buildCallOptions(
|
|
resolvedProvider: ProviderType,
|
|
options: RunAgentOptions,
|
|
agentConfig?: CustomAgentConfig,
|
|
): ProviderCallOptions {
|
|
return {
|
|
cwd: options.cwd,
|
|
abortSignal: options.abortSignal,
|
|
sessionId: options.sessionId,
|
|
allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
|
|
mcpServers: options.mcpServers,
|
|
maxTurns: options.maxTurns,
|
|
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
|
|
permissionMode: options.permissionMode,
|
|
providerOptions: options.providerOptions,
|
|
onStream: options.onStream,
|
|
onPermissionRequest: options.onPermissionRequest,
|
|
onAskUserQuestion: options.onAskUserQuestion,
|
|
bypassPermissions: options.bypassPermissions,
|
|
outputSchema: options.outputSchema,
|
|
};
|
|
}
|
|
|
|
/** Run a custom agent */
|
|
async runCustom(
|
|
agentConfig: CustomAgentConfig,
|
|
task: string,
|
|
options: RunAgentOptions,
|
|
): Promise<AgentResponse> {
|
|
const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig);
|
|
const provider = getProvider(providerType);
|
|
|
|
const agent = provider.setup({
|
|
name: agentConfig.name,
|
|
systemPrompt: agentConfig.claudeAgent || agentConfig.claudeSkill
|
|
? undefined
|
|
: loadAgentPrompt(agentConfig),
|
|
claudeAgent: agentConfig.claudeAgent,
|
|
claudeSkill: agentConfig.claudeSkill,
|
|
});
|
|
|
|
return agent.call(task, AgentRunner.buildCallOptions(providerType, options, agentConfig));
|
|
}
|
|
|
|
/** Run an agent by name, path, inline prompt string, or no agent at all */
|
|
async run(
|
|
personaSpec: string | undefined,
|
|
task: string,
|
|
options: RunAgentOptions,
|
|
): Promise<AgentResponse> {
|
|
const personaName = personaSpec ? AgentRunner.extractPersonaName(personaSpec) : 'default';
|
|
log.debug('Running agent', {
|
|
personaSpec: personaSpec ?? '(none)',
|
|
personaName,
|
|
provider: options.provider,
|
|
model: options.model,
|
|
hasPersonaPath: !!options.personaPath,
|
|
hasSession: !!options.sessionId,
|
|
permissionMode: options.permissionMode,
|
|
});
|
|
|
|
const providerType = AgentRunner.resolveProvider(options.cwd, options);
|
|
const provider = getProvider(providerType);
|
|
const callOptions = AgentRunner.buildCallOptions(providerType, options);
|
|
|
|
// 1. If personaPath is provided (resolved file exists), load prompt from file
|
|
// and wrap it through the perform_agent_system_prompt template
|
|
if (options.personaPath) {
|
|
const agentDefinition = AgentRunner.loadPersonaPromptFromPath(options.personaPath);
|
|
const language = options.language ?? 'en';
|
|
const templateVars: Record<string, string> = { agentDefinition };
|
|
|
|
if (options.pieceMeta) {
|
|
templateVars.pieceName = options.pieceMeta.pieceName;
|
|
templateVars.pieceDescription = options.pieceMeta.pieceDescription ?? '';
|
|
templateVars.currentMovement = options.pieceMeta.currentMovement;
|
|
templateVars.movementsList = options.pieceMeta.movementsList
|
|
.map((m, i) => `${i + 1}. ${m.name}${m.description ? ` - ${m.description}` : ''}`)
|
|
.join('\n');
|
|
templateVars.currentPosition = options.pieceMeta.currentPosition;
|
|
}
|
|
|
|
const systemPrompt = loadTemplate('perform_agent_system_prompt', language, templateVars);
|
|
const agent = provider.setup({ name: personaName, systemPrompt });
|
|
return agent.call(task, callOptions);
|
|
}
|
|
|
|
// 2. If personaSpec is provided but no personaPath (file not found), try custom agent first,
|
|
// then use the string as inline system prompt
|
|
if (personaSpec) {
|
|
const customAgents = loadCustomAgents();
|
|
const agentConfig = customAgents.get(personaName);
|
|
if (agentConfig) {
|
|
return this.runCustom(agentConfig, task, options);
|
|
}
|
|
|
|
const agent = provider.setup({ name: personaName, systemPrompt: personaSpec });
|
|
return agent.call(task, callOptions);
|
|
}
|
|
|
|
// 3. No persona specified — run with instruction_template only (no system prompt)
|
|
const agent = provider.setup({ name: personaName });
|
|
return agent.call(task, callOptions);
|
|
}
|
|
}
|
|
|
|
// ---- Module-level function facade ----
|
|
|
|
const defaultRunner = new AgentRunner();
|
|
|
|
export async function runAgent(
|
|
personaSpec: string | undefined,
|
|
task: string,
|
|
options: RunAgentOptions,
|
|
): Promise<AgentResponse> {
|
|
return defaultRunner.run(personaSpec, task, options);
|
|
}
|