agent 周りの抽象化

This commit is contained in:
nrslib 2026-02-07 10:15:41 +09:00
parent 1df353148e
commit e23cfa9a3b
12 changed files with 272 additions and 239 deletions

View File

@ -119,6 +119,50 @@ function processOrder(order) {
- 独自の書き方より標準的な書き方を選ぶ - 独自の書き方より標準的な書き方を選ぶ
- 不明なときはリサーチする。推測で実装しない - 不明なときはリサーチする。推測で実装しない
### インターフェース設計
インターフェースは利用側の都合で設計する。実装側の内部構造を露出しない。
| 原則 | 基準 |
|------|------|
| 利用者視点 | 呼び出し側が必要としないものを押し付けない |
| 構成と実行の分離 | 「何を使うか」はセットアップ時に決定し、実行APIはシンプルに保つ |
| メソッド増殖の禁止 | 同じことをする複数メソッドは構成の違いで吸収する |
```typescript
// ❌ メソッド増殖 — 構成の違いを呼び出し側に押し付けている
interface Provider {
call(name, prompt, options)
callCustom(name, prompt, systemPrompt, options)
callAgent(name, prompt, options)
callSkill(name, prompt, options)
}
// ✅ 構成と実行の分離
interface Provider {
setup(config: Setup): Agent
}
interface Agent {
call(prompt, options): Promise<Response>
}
```
### 抽象化の漏れ
特定実装が汎用層に現れたら抽象化が漏れている。汎用層はインターフェースだけを知り、分岐は実装側で吸収する。
```typescript
// ❌ 汎用層に特定実装のインポートと分岐
import { callSpecificImpl } from '../specific/index.js'
if (config.specificFlag) {
return callSpecificImpl(config.name, task, options)
}
// ✅ 汎用層はインターフェースのみ。非対応は setup 時にエラー
const agent = provider.setup({ specificFlag: config.specificFlag })
return agent.call(task, options)
```
## 構造 ## 構造
### 分割の基準 ### 分割の基準

View File

@ -38,6 +38,8 @@
- エラーの握りつぶし(空の catch - エラーの握りつぶし(空の catch
- TODO コメントIssue化されていないもの - TODO コメントIssue化されていないもの
- 3箇所以上の重複コードDRY違反 - 3箇所以上の重複コードDRY違反
- 同じことをするメソッドの増殖(構成の違いで吸収すべき)
- 特定実装の汎用層への漏洩(汎用層に特定実装のインポート・分岐がある)
### Warning警告 ### Warning警告

View File

@ -105,18 +105,19 @@ function setupInputSequence(inputs: (string | null)[]): void {
/** Create a mock provider that returns given responses */ /** Create a mock provider that returns given responses */
function setupMockProvider(responses: string[]): void { function setupMockProvider(responses: string[]): void {
let callIndex = 0; let callIndex = 0;
const mockCall = vi.fn(async () => {
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
callIndex++;
return {
persona: 'interactive',
status: 'done' as const,
content: content!,
timestamp: new Date(),
};
});
const mockProvider = { const mockProvider = {
call: vi.fn(async () => { setup: () => ({ call: mockCall }),
const content = callIndex < responses.length ? responses[callIndex] : 'AI response'; _call: mockCall,
callIndex++;
return {
persona: 'interactive',
status: 'done' as const,
content: content!,
timestamp: new Date(),
};
}),
callCustom: vi.fn(),
}; };
mockGetProvider.mockReturnValue(mockProvider); mockGetProvider.mockReturnValue(mockProvider);
} }
@ -162,9 +163,8 @@ describe('interactiveMode', () => {
await interactiveMode('/project'); await interactiveMode('/project');
// Then // Then
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).toHaveBeenCalledWith( expect(mockProvider._call).toHaveBeenCalledWith(
'interactive',
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
cwd: '/project', cwd: '/project',
@ -208,8 +208,8 @@ describe('interactiveMode', () => {
// Then // Then
expect(result.action).toBe('execute'); expect(result.action).toBe('execute');
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).toHaveBeenCalledTimes(2); expect(mockProvider._call).toHaveBeenCalledTimes(2);
}); });
it('should accumulate conversation history across multiple turns', async () => { it('should accumulate conversation history across multiple turns', async () => {
@ -223,8 +223,8 @@ describe('interactiveMode', () => {
// Then: task should be a summary and prompt should include full history // Then: task should be a summary and prompt should include full history
expect(result.action).toBe('execute'); expect(result.action).toBe('execute');
expect(result.task).toBe('Summarized task.'); expect(result.task).toBe('Summarized task.');
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
const summaryPrompt = mockProvider.call.mock.calls[2]?.[1] as string; const summaryPrompt = mockProvider._call.mock.calls[2]?.[0] as string;
expect(summaryPrompt).toContain('Conversation:'); expect(summaryPrompt).toContain('Conversation:');
expect(summaryPrompt).toContain('User: first message'); expect(summaryPrompt).toContain('User: first message');
expect(summaryPrompt).toContain('Assistant: response to first'); expect(summaryPrompt).toContain('Assistant: response to first');
@ -241,9 +241,9 @@ describe('interactiveMode', () => {
await interactiveMode('/project'); await interactiveMode('/project');
// Then: each call receives only the current user input (session maintains context) // Then: each call receives only the current user input (session maintains context)
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('first msg'); expect(mockProvider._call.mock.calls[0]?.[0]).toBe('first msg');
expect(mockProvider.call.mock.calls[1]?.[1]).toBe('second msg'); expect(mockProvider._call.mock.calls[1]?.[0]).toBe('second msg');
}); });
it('should process initialInput as first message before entering loop', async () => { it('should process initialInput as first message before entering loop', async () => {
@ -255,9 +255,9 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project', 'a'); const result = await interactiveMode('/project', 'a');
// Then: AI should have been called with initialInput // Then: AI should have been called with initialInput
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).toHaveBeenCalledTimes(2); expect(mockProvider._call).toHaveBeenCalledTimes(2);
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); expect(mockProvider._call.mock.calls[0]?.[0]).toBe('a');
// /go should work because initialInput already started conversation // /go should work because initialInput already started conversation
expect(result.action).toBe('execute'); expect(result.action).toBe('execute');
@ -273,10 +273,10 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project', 'a'); const result = await interactiveMode('/project', 'a');
// Then: each call receives only its own input (session handles history) // Then: each call receives only its own input (session handles history)
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).toHaveBeenCalledTimes(3); expect(mockProvider._call).toHaveBeenCalledTimes(3);
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); expect(mockProvider._call.mock.calls[0]?.[0]).toBe('a');
expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page'); expect(mockProvider._call.mock.calls[1]?.[0]).toBe('fix the login page');
// Task still contains all history for downstream use // Task still contains all history for downstream use
expect(result.action).toBe('execute'); expect(result.action).toBe('execute');
@ -332,7 +332,7 @@ describe('interactiveMode', () => {
// Then: provider should NOT have been called (no summary needed) // Then: provider should NOT have been called (no summary needed)
const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType<typeof vi.fn> }; const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).not.toHaveBeenCalled(); expect(mockProvider._call).not.toHaveBeenCalled();
expect(result.action).toBe('execute'); expect(result.action).toBe('execute');
expect(result.task).toBe('quick task'); expect(result.task).toBe('quick task');
}); });

View File

@ -31,8 +31,7 @@ const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockProviderCall = vi.fn(); const mockProviderCall = vi.fn();
const mockProvider = { const mockProvider = {
call: mockProviderCall, setup: () => ({ call: mockProviderCall }),
callCustom: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {
@ -65,7 +64,6 @@ describe('summarizeTaskName', () => {
expect(result).toBe('add-auth'); expect(result).toBe('add-auth');
expect(mockGetProvider).toHaveBeenCalledWith('claude'); expect(mockGetProvider).toHaveBeenCalledWith('claude');
expect(mockProviderCall).toHaveBeenCalledWith( expect(mockProviderCall).toHaveBeenCalledWith(
'summarizer',
'long task name for testing', 'long task name for testing',
expect.objectContaining({ expect.objectContaining({
cwd: '/project', cwd: '/project',
@ -154,7 +152,6 @@ describe('summarizeTaskName', () => {
// Then // Then
expect(mockProviderCall).toHaveBeenCalledWith( expect(mockProviderCall).toHaveBeenCalledWith(
'summarizer',
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
model: 'sonnet', model: 'sonnet',
@ -185,7 +182,6 @@ describe('summarizeTaskName', () => {
// Then // Then
expect(mockGetProvider).toHaveBeenCalledWith('codex'); expect(mockGetProvider).toHaveBeenCalledWith('codex');
expect(mockProviderCall).toHaveBeenCalledWith( expect(mockProviderCall).toHaveBeenCalledWith(
'summarizer',
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
model: 'gpt-4', model: 'gpt-4',

View File

@ -4,11 +4,6 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { basename, dirname } from 'node:path'; import { basename, dirname } from 'node:path';
import {
callClaudeAgent,
callClaudeSkill,
type ClaudeCallOptions,
} from '../infra/claude/index.js';
import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js';
import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
@ -46,9 +41,13 @@ export class AgentRunner {
return 'claude'; return 'claude';
} }
/** Resolve model from options, agent config, global config */ /**
* 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( private static resolveModel(
cwd: string, resolvedProvider: ProviderType,
options?: RunAgentOptions, options?: RunAgentOptions,
agentConfig?: CustomAgentConfig, agentConfig?: CustomAgentConfig,
): string | undefined { ): string | undefined {
@ -56,7 +55,10 @@ export class AgentRunner {
if (agentConfig?.model) return agentConfig.model; if (agentConfig?.model) return agentConfig.model;
try { try {
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
if (globalConfig.model) return globalConfig.model; if (globalConfig.model) {
const globalProvider = globalConfig.provider ?? 'claude';
if (globalProvider === resolvedProvider) return globalConfig.model;
}
} catch { } catch {
// Ignore missing global config // Ignore missing global config
} }
@ -90,88 +92,45 @@ export class AgentRunner {
return `${dir}/${name}`; return `${dir}/${name}`;
} }
/** Build ProviderCallOptions from RunAgentOptions */
private static buildCallOptions(
resolvedProvider: ProviderType,
options: RunAgentOptions,
agentConfig?: CustomAgentConfig,
): ProviderCallOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
}
/** Run a custom agent */ /** Run a custom agent */
async runCustom( async runCustom(
agentConfig: CustomAgentConfig, agentConfig: CustomAgentConfig,
task: string, task: string,
options: RunAgentOptions, options: RunAgentOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const allowedTools = options.allowedTools ?? agentConfig.allowedTools;
// If agent references a Claude Code agent
if (agentConfig.claudeAgent) {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return callClaudeAgent(agentConfig.claudeAgent, task, callOptions);
}
// If agent references a Claude Code skill
if (agentConfig.claudeSkill) {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return callClaudeSkill(agentConfig.claudeSkill, task, callOptions);
}
// Custom agent with prompt
const systemPrompt = loadAgentPrompt(agentConfig);
const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig); const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig);
const provider = getProvider(providerType); const provider = getProvider(providerType);
const callOptions: ProviderCallOptions = { const agent = provider.setup({
cwd: options.cwd, name: agentConfig.name,
sessionId: options.sessionId, systemPrompt: agentConfig.claudeAgent || agentConfig.claudeSkill
allowedTools, ? undefined
maxTurns: options.maxTurns, : loadAgentPrompt(agentConfig),
model: AgentRunner.resolveModel(options.cwd, options, agentConfig), claudeAgent: agentConfig.claudeAgent,
permissionMode: options.permissionMode, claudeSkill: agentConfig.claudeSkill,
onStream: options.onStream, });
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions); return agent.call(task, AgentRunner.buildCallOptions(providerType, options, agentConfig));
}
/** Build ProviderCallOptions from RunAgentOptions with optional systemPrompt override */
private static buildProviderCallOptions(
options: RunAgentOptions,
systemPrompt?: string,
): ProviderCallOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options),
systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
} }
/** Run an agent by name, path, inline prompt string, or no agent at all */ /** Run an agent by name, path, inline prompt string, or no agent at all */
@ -191,6 +150,10 @@ export class AgentRunner {
permissionMode: options.permissionMode, 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 // 1. If personaPath is provided (resolved file exists), load prompt from file
// and wrap it through the perform_agent_system_prompt template // and wrap it through the perform_agent_system_prompt template
if (options.personaPath) { if (options.personaPath) {
@ -198,7 +161,6 @@ export class AgentRunner {
const language = options.language ?? 'en'; const language = options.language ?? 'en';
const templateVars: Record<string, string> = { agentDefinition }; const templateVars: Record<string, string> = { agentDefinition };
// Add piece meta information if available
if (options.pieceMeta) { if (options.pieceMeta) {
templateVars.pieceName = options.pieceMeta.pieceName; templateVars.pieceName = options.pieceMeta.pieceName;
templateVars.pieceDescription = options.pieceMeta.pieceDescription ?? ''; templateVars.pieceDescription = options.pieceMeta.pieceDescription ?? '';
@ -210,9 +172,8 @@ export class AgentRunner {
} }
const systemPrompt = loadTemplate('perform_agent_system_prompt', language, templateVars); const systemPrompt = loadTemplate('perform_agent_system_prompt', language, templateVars);
const providerType = AgentRunner.resolveProvider(options.cwd, options); const agent = provider.setup({ name: personaName, systemPrompt });
const provider = getProvider(providerType); return agent.call(task, callOptions);
return provider.call(personaName, task, AgentRunner.buildProviderCallOptions(options, systemPrompt));
} }
// 2. If personaSpec is provided but no personaPath (file not found), try custom agent first, // 2. If personaSpec is provided but no personaPath (file not found), try custom agent first,
@ -224,16 +185,13 @@ export class AgentRunner {
return this.runCustom(agentConfig, task, options); return this.runCustom(agentConfig, task, options);
} }
// Use personaSpec string as inline system prompt const agent = provider.setup({ name: personaName, systemPrompt: personaSpec });
const providerType = AgentRunner.resolveProvider(options.cwd, options); return agent.call(task, callOptions);
const provider = getProvider(providerType);
return provider.call(personaName, task, AgentRunner.buildProviderCallOptions(options, personaSpec));
} }
// 3. No persona specified — run with instruction_template only (no system prompt) // 3. No persona specified — run with instruction_template only (no system prompt)
const providerType = AgentRunner.resolveProvider(options.cwd, options); const agent = provider.setup({ name: personaName });
const provider = getProvider(providerType); return agent.call(task, callOptions);
return provider.call(personaName, task, AgentRunner.buildProviderCallOptions(options));
} }
} }

View File

@ -221,11 +221,11 @@ async function callAI(
display: StreamDisplay, display: StreamDisplay,
systemPrompt: string, systemPrompt: string,
): Promise<CallAIResult> { ): Promise<CallAIResult> {
const response = await provider.call('interactive', prompt, { const agent = provider.setup({ name: 'interactive', systemPrompt });
const response = await agent.call(prompt, {
cwd, cwd,
model, model,
sessionId, sessionId,
systemPrompt,
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
onStream: display.createHandler(), onStream: display.createHandler(),
}); });

View File

@ -2,47 +2,57 @@
* Claude provider implementation * Claude provider implementation
*/ */
import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/index.js'; import { callClaude, callClaudeCustom, callClaudeAgent, callClaudeSkill, type ClaudeCallOptions } from '../claude/index.js';
import { resolveAnthropicApiKey } from '../config/index.js'; import { resolveAnthropicApiKey } from '../config/index.js';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import type { Provider, ProviderCallOptions } from './types.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
/** Claude provider - wraps existing Claude client */ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(),
};
}
/** Claude provider — delegates to Claude Code SDK */
export class ClaudeProvider implements Provider { export class ClaudeProvider implements Provider {
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> { setup(config: AgentSetup): ProviderAgent {
const callOptions: ClaudeCallOptions = { if (config.claudeAgent) {
cwd: options.cwd, const agentName = config.claudeAgent;
sessionId: options.sessionId, return {
allowedTools: options.allowedTools, call: (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> =>
model: options.model, callClaudeAgent(agentName, prompt, toClaudeOptions(options)),
maxTurns: options.maxTurns, };
systemPrompt: options.systemPrompt, }
permissionMode: options.permissionMode,
onStream: options.onStream, if (config.claudeSkill) {
onPermissionRequest: options.onPermissionRequest, const skillName = config.claudeSkill;
onAskUserQuestion: options.onAskUserQuestion, return {
bypassPermissions: options.bypassPermissions, call: (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> =>
anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), callClaudeSkill(skillName, prompt, toClaudeOptions(options)),
};
}
const { name, systemPrompt } = config;
if (systemPrompt) {
return {
call: (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> =>
callClaudeCustom(name, prompt, systemPrompt, toClaudeOptions(options)),
};
}
return {
call: (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> =>
callClaude(name, prompt, toClaudeOptions(options)),
}; };
return callClaude(agentName, prompt, callOptions);
}
async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(),
};
return callClaudeCustom(agentName, prompt, systemPrompt, callOptions);
} }
} }

View File

@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process';
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js';
import { resolveOpenaiApiKey } from '../config/index.js'; import { resolveOpenaiApiKey } from '../config/index.js';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import type { Provider, ProviderCallOptions } from './types.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
const NOT_GIT_REPO_MESSAGE = const NOT_GIT_REPO_MESSAGE =
'Codex をご利用の場合 Git 管理下のディレクトリでのみ動作します。'; 'Codex をご利用の場合 Git 管理下のディレクトリでのみ動作します。';
@ -24,50 +24,51 @@ function isInsideGitRepo(cwd: string): boolean {
} }
} }
/** Codex provider - wraps existing Codex client */ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
model: options.model,
permissionMode: options.permissionMode,
onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
};
}
function blockedResponse(agentName: string): AgentResponse {
return {
persona: agentName,
status: 'blocked',
content: NOT_GIT_REPO_MESSAGE,
timestamp: new Date(),
};
}
/** Codex provider — delegates to OpenAI Codex SDK */
export class CodexProvider implements Provider { export class CodexProvider implements Provider {
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> { setup(config: AgentSetup): ProviderAgent {
if (!isInsideGitRepo(options.cwd)) { if (config.claudeAgent) {
throw new Error('Claude Code agent calls are not supported by the Codex provider');
}
if (config.claudeSkill) {
throw new Error('Claude Code skill calls are not supported by the Codex provider');
}
const { name, systemPrompt } = config;
if (systemPrompt) {
return { return {
persona: agentName, call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
status: 'blocked', if (!isInsideGitRepo(options.cwd)) return blockedResponse(name);
content: NOT_GIT_REPO_MESSAGE, return callCodexCustom(name, prompt, systemPrompt, toCodexOptions(options));
timestamp: new Date(), },
}; };
} }
const callOptions: CodexCallOptions = { return {
cwd: options.cwd, call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
sessionId: options.sessionId, if (!isInsideGitRepo(options.cwd)) return blockedResponse(name);
model: options.model, return callCodex(name, prompt, toCodexOptions(options));
systemPrompt: options.systemPrompt, },
permissionMode: options.permissionMode,
onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
}; };
return callCodex(agentName, prompt, callOptions);
}
async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
if (!isInsideGitRepo(options.cwd)) {
return {
persona: agentName,
status: 'blocked',
content: NOT_GIT_REPO_MESSAGE,
timestamp: new Date(),
};
}
const callOptions: CodexCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
model: options.model,
permissionMode: options.permissionMode,
onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
};
return callCodexCustom(agentName, prompt, systemPrompt, callOptions);
} }
} }

View File

@ -10,7 +10,7 @@ import { CodexProvider } from './codex.js';
import { MockProvider } from './mock.js'; import { MockProvider } from './mock.js';
import type { Provider, ProviderType } from './types.js'; import type { Provider, ProviderType } from './types.js';
export type { ProviderCallOptions, Provider, ProviderType } from './types.js'; export type { AgentSetup, ProviderCallOptions, ProviderAgent, Provider, ProviderType } from './types.js';
/** /**
* Registry for agent providers. * Registry for agent providers.

View File

@ -4,27 +4,37 @@
import { callMock, callMockCustom, type MockCallOptions } from '../mock/index.js'; import { callMock, callMockCustom, type MockCallOptions } from '../mock/index.js';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import type { Provider, ProviderCallOptions } from './types.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
/** Mock provider - wraps existing Mock client */ function toMockOptions(options: ProviderCallOptions): MockCallOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
onStream: options.onStream,
};
}
/** Mock provider — deterministic responses for testing */
export class MockProvider implements Provider { export class MockProvider implements Provider {
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> { setup(config: AgentSetup): ProviderAgent {
const callOptions: MockCallOptions = { if (config.claudeAgent) {
cwd: options.cwd, throw new Error('Claude Code agent calls are not supported by the Mock provider');
sessionId: options.sessionId, }
onStream: options.onStream, if (config.claudeSkill) {
throw new Error('Claude Code skill calls are not supported by the Mock provider');
}
const { name, systemPrompt } = config;
if (systemPrompt) {
return {
call: (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> =>
callMockCustom(name, prompt, systemPrompt, toMockOptions(options)),
};
}
return {
call: (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> =>
callMock(name, prompt, toMockOptions(options)),
}; };
return callMock(agentName, prompt, callOptions);
}
async callCustom(agentName: string, prompt: string, _systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
const callOptions: MockCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
onStream: options.onStream,
};
return callMockCustom(agentName, prompt, _systemPrompt, callOptions);
} }
} }

View File

@ -5,12 +5,23 @@
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js'; import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js';
import type { AgentResponse, PermissionMode } from '../../core/models/index.js'; import type { AgentResponse, PermissionMode } from '../../core/models/index.js';
/** Common options for all providers */ /** Agent setup configuration — determines HOW the provider invokes the agent */
export interface AgentSetup {
/** Display name for this agent */
name: string;
/** System prompt for the agent (persona content, inline prompt, etc.) */
systemPrompt?: string;
/** Delegate to a Claude Code agent by name (Claude provider only) */
claudeAgent?: string;
/** Delegate to a Claude Code skill by name (Claude provider only) */
claudeSkill?: string;
}
/** Runtime options passed at call time */
export interface ProviderCallOptions { export interface ProviderCallOptions {
cwd: string; cwd: string;
sessionId?: string; sessionId?: string;
model?: string; model?: string;
systemPrompt?: string;
allowedTools?: string[]; allowedTools?: string[];
/** Maximum number of agentic turns */ /** Maximum number of agentic turns */
maxTurns?: number; maxTurns?: number;
@ -26,13 +37,14 @@ export interface ProviderCallOptions {
openaiApiKey?: string; openaiApiKey?: string;
} }
/** Provider interface - all providers must implement this */ /** A configured agent ready to be called */
export interface Provider { export interface ProviderAgent {
/** Call the provider with a prompt (using systemPrompt from options if provided) */ call(prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>; }
/** Call the provider with explicit system prompt */ /** Provider interface — creates configured agents from setup */
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>; export interface Provider {
setup(config: AgentSetup): ProviderAgent;
} }
/** Provider type */ /** Provider type */

View File

@ -66,15 +66,15 @@ export class TaskSummarizer {
const model = options.model ?? globalConfig.model; const model = options.model ?? globalConfig.model;
const provider = getProvider(providerType); const provider = getProvider(providerType);
const callOptions: SummarizeOptions & { systemPrompt: string; allowedTools: [] } = { const agent = provider.setup({
cwd: options.cwd, name: 'summarizer',
systemPrompt: loadTemplate('score_slug_system_prompt', 'en'), systemPrompt: loadTemplate('score_slug_system_prompt', 'en'),
});
const response = await agent.call(taskName, {
cwd: options.cwd,
model,
allowedTools: [], allowedTools: [],
}; });
if (model) {
callOptions.model = model;
}
const response = await provider.call('summarizer', taskName, callOptions);
const slug = sanitizeSlug(response.content); const slug = sanitizeSlug(response.content);
log.info('Task name summarized', { original: taskName, slug }); log.info('Task name summarized', { original: taskName, slug });