From e23cfa9a3b9f6a1cbdceceaf4f48a7241feb048b Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:15:41 +0900 Subject: [PATCH] =?UTF-8?q?agent=20=E5=91=A8=E3=82=8A=E3=81=AE=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/global/ja/stances/coding.md | 44 ++++++++ resources/global/ja/stances/review.md | 2 + src/__tests__/interactive.test.ts | 58 +++++----- src/__tests__/summarize.test.ts | 6 +- src/agents/runner.ts | 140 +++++++++--------------- src/features/interactive/interactive.ts | 4 +- src/infra/providers/claude.ts | 84 +++++++------- src/infra/providers/codex.ts | 81 +++++++------- src/infra/providers/index.ts | 2 +- src/infra/providers/mock.ts | 48 ++++---- src/infra/providers/types.ts | 28 +++-- src/infra/task/summarize.ts | 14 +-- 12 files changed, 272 insertions(+), 239 deletions(-) diff --git a/resources/global/ja/stances/coding.md b/resources/global/ja/stances/coding.md index 9198283..15574e4 100644 --- a/resources/global/ja/stances/coding.md +++ b/resources/global/ja/stances/coding.md @@ -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 +} +``` + +### 抽象化の漏れ + +特定実装が汎用層に現れたら抽象化が漏れている。汎用層はインターフェースだけを知り、分岐は実装側で吸収する。 + +```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) +``` + ## 構造 ### 分割の基準 diff --git a/resources/global/ja/stances/review.md b/resources/global/ja/stances/review.md index ab76414..94daa81 100644 --- a/resources/global/ja/stances/review.md +++ b/resources/global/ja/stances/review.md @@ -38,6 +38,8 @@ - エラーの握りつぶし(空の catch) - TODO コメント(Issue化されていないもの) - 3箇所以上の重複コード(DRY違反) +- 同じことをするメソッドの増殖(構成の違いで吸収すべき) +- 特定実装の汎用層への漏洩(汎用層に特定実装のインポート・分岐がある) ### Warning(警告) diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 89d0530..4ed8401 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -105,18 +105,19 @@ function setupInputSequence(inputs: (string | null)[]): void { /** Create a mock provider that returns given responses */ function setupMockProvider(responses: string[]): void { 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 = { - call: 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(), - }; - }), - callCustom: vi.fn(), + setup: () => ({ call: mockCall }), + _call: mockCall, }; mockGetProvider.mockReturnValue(mockProvider); } @@ -162,9 +163,8 @@ describe('interactiveMode', () => { await interactiveMode('/project'); // Then - const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; - expect(mockProvider.call).toHaveBeenCalledWith( - 'interactive', + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ cwd: '/project', @@ -208,8 +208,8 @@ describe('interactiveMode', () => { // Then expect(result.action).toBe('execute'); - const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; - expect(mockProvider.call).toHaveBeenCalledTimes(2); + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledTimes(2); }); 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 expect(result.action).toBe('execute'); expect(result.task).toBe('Summarized task.'); - const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; - const summaryPrompt = mockProvider.call.mock.calls[2]?.[1] as string; + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const summaryPrompt = mockProvider._call.mock.calls[2]?.[0] as string; expect(summaryPrompt).toContain('Conversation:'); expect(summaryPrompt).toContain('User: first message'); expect(summaryPrompt).toContain('Assistant: response to first'); @@ -241,9 +241,9 @@ describe('interactiveMode', () => { await interactiveMode('/project'); // Then: each call receives only the current user input (session maintains context) - const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; - expect(mockProvider.call.mock.calls[0]?.[1]).toBe('first msg'); - expect(mockProvider.call.mock.calls[1]?.[1]).toBe('second msg'); + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call.mock.calls[0]?.[0]).toBe('first msg'); + expect(mockProvider._call.mock.calls[1]?.[0]).toBe('second msg'); }); it('should process initialInput as first message before entering loop', async () => { @@ -255,9 +255,9 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project', 'a'); // Then: AI should have been called with initialInput - const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; - expect(mockProvider.call).toHaveBeenCalledTimes(2); - expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledTimes(2); + expect(mockProvider._call.mock.calls[0]?.[0]).toBe('a'); // /go should work because initialInput already started conversation expect(result.action).toBe('execute'); @@ -273,10 +273,10 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project', 'a'); // Then: each call receives only its own input (session handles history) - const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; - expect(mockProvider.call).toHaveBeenCalledTimes(3); - expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); - expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page'); + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledTimes(3); + expect(mockProvider._call.mock.calls[0]?.[0]).toBe('a'); + expect(mockProvider._call.mock.calls[1]?.[0]).toBe('fix the login page'); // Task still contains all history for downstream use expect(result.action).toBe('execute'); @@ -332,7 +332,7 @@ describe('interactiveMode', () => { // Then: provider should NOT have been called (no summary needed) const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType }; - expect(mockProvider.call).not.toHaveBeenCalled(); + expect(mockProvider._call).not.toHaveBeenCalled(); expect(result.action).toBe('execute'); expect(result.task).toBe('quick task'); }); diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index c87f95e..20ba865 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -31,8 +31,7 @@ const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockProviderCall = vi.fn(); const mockProvider = { - call: mockProviderCall, - callCustom: vi.fn(), + setup: () => ({ call: mockProviderCall }), }; beforeEach(() => { @@ -65,7 +64,6 @@ describe('summarizeTaskName', () => { expect(result).toBe('add-auth'); expect(mockGetProvider).toHaveBeenCalledWith('claude'); expect(mockProviderCall).toHaveBeenCalledWith( - 'summarizer', 'long task name for testing', expect.objectContaining({ cwd: '/project', @@ -154,7 +152,6 @@ describe('summarizeTaskName', () => { // Then expect(mockProviderCall).toHaveBeenCalledWith( - 'summarizer', expect.any(String), expect.objectContaining({ model: 'sonnet', @@ -185,7 +182,6 @@ describe('summarizeTaskName', () => { // Then expect(mockGetProvider).toHaveBeenCalledWith('codex'); expect(mockProviderCall).toHaveBeenCalledWith( - 'summarizer', expect.any(String), expect.objectContaining({ model: 'gpt-4', diff --git a/src/agents/runner.ts b/src/agents/runner.ts index a29ecf8..277f6f2 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -4,11 +4,6 @@ import { existsSync, readFileSync } from 'node:fs'; 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 { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; @@ -46,9 +41,13 @@ export class AgentRunner { 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( - cwd: string, + resolvedProvider: ProviderType, options?: RunAgentOptions, agentConfig?: CustomAgentConfig, ): string | undefined { @@ -56,7 +55,10 @@ export class AgentRunner { if (agentConfig?.model) return agentConfig.model; try { const globalConfig = loadGlobalConfig(); - if (globalConfig.model) return globalConfig.model; + if (globalConfig.model) { + const globalProvider = globalConfig.provider ?? 'claude'; + if (globalProvider === resolvedProvider) return globalConfig.model; + } } catch { // Ignore missing global config } @@ -90,88 +92,45 @@ export class AgentRunner { 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 */ async runCustom( agentConfig: CustomAgentConfig, task: string, options: RunAgentOptions, ): Promise { - 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 provider = getProvider(providerType); - const callOptions: ProviderCallOptions = { - 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, - }; + const agent = provider.setup({ + name: agentConfig.name, + systemPrompt: agentConfig.claudeAgent || agentConfig.claudeSkill + ? undefined + : loadAgentPrompt(agentConfig), + claudeAgent: agentConfig.claudeAgent, + claudeSkill: agentConfig.claudeSkill, + }); - return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions); - } - - /** 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, - }; + return agent.call(task, AgentRunner.buildCallOptions(providerType, options, agentConfig)); } /** Run an agent by name, path, inline prompt string, or no agent at all */ @@ -191,6 +150,10 @@ export class AgentRunner { 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) { @@ -198,7 +161,6 @@ export class AgentRunner { const language = options.language ?? 'en'; const templateVars: Record = { agentDefinition }; - // Add piece meta information if available if (options.pieceMeta) { templateVars.pieceName = options.pieceMeta.pieceName; templateVars.pieceDescription = options.pieceMeta.pieceDescription ?? ''; @@ -210,9 +172,8 @@ export class AgentRunner { } const systemPrompt = loadTemplate('perform_agent_system_prompt', language, templateVars); - const providerType = AgentRunner.resolveProvider(options.cwd, options); - const provider = getProvider(providerType); - return provider.call(personaName, task, AgentRunner.buildProviderCallOptions(options, systemPrompt)); + 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, @@ -224,16 +185,13 @@ export class AgentRunner { return this.runCustom(agentConfig, task, options); } - // Use personaSpec string as inline system prompt - const providerType = AgentRunner.resolveProvider(options.cwd, options); - const provider = getProvider(providerType); - return provider.call(personaName, task, AgentRunner.buildProviderCallOptions(options, personaSpec)); + 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 providerType = AgentRunner.resolveProvider(options.cwd, options); - const provider = getProvider(providerType); - return provider.call(personaName, task, AgentRunner.buildProviderCallOptions(options)); + const agent = provider.setup({ name: personaName }); + return agent.call(task, callOptions); } } diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 69fe6a4..b0e7190 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -221,11 +221,11 @@ async function callAI( display: StreamDisplay, systemPrompt: string, ): Promise { - const response = await provider.call('interactive', prompt, { + const agent = provider.setup({ name: 'interactive', systemPrompt }); + const response = await agent.call(prompt, { cwd, model, sessionId, - systemPrompt, allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], onStream: display.createHandler(), }); diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index 13f6f01..02a8cf8 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -2,47 +2,57 @@ * 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 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 { - async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise { - const callOptions: ClaudeCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools: options.allowedTools, - model: options.model, - maxTurns: options.maxTurns, - systemPrompt: options.systemPrompt, - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), + setup(config: AgentSetup): ProviderAgent { + if (config.claudeAgent) { + const agentName = config.claudeAgent; + return { + call: (prompt: string, options: ProviderCallOptions): Promise => + callClaudeAgent(agentName, prompt, toClaudeOptions(options)), + }; + } + + if (config.claudeSkill) { + const skillName = config.claudeSkill; + return { + call: (prompt: string, options: ProviderCallOptions): Promise => + callClaudeSkill(skillName, prompt, toClaudeOptions(options)), + }; + } + + const { name, systemPrompt } = config; + if (systemPrompt) { + return { + call: (prompt: string, options: ProviderCallOptions): Promise => + callClaudeCustom(name, prompt, systemPrompt, toClaudeOptions(options)), + }; + } + + return { + call: (prompt: string, options: ProviderCallOptions): Promise => + callClaude(name, prompt, toClaudeOptions(options)), }; - - return callClaude(agentName, prompt, callOptions); - } - - async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise { - 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); } } diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index 7218b86..3ccdd46 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; import { resolveOpenaiApiKey } from '../config/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 = '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 { - async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise { - if (!isInsideGitRepo(options.cwd)) { + setup(config: AgentSetup): ProviderAgent { + 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 { - persona: agentName, - status: 'blocked', - content: NOT_GIT_REPO_MESSAGE, - timestamp: new Date(), + call: async (prompt: string, options: ProviderCallOptions): Promise => { + if (!isInsideGitRepo(options.cwd)) return blockedResponse(name); + return callCodexCustom(name, prompt, systemPrompt, toCodexOptions(options)); + }, }; } - const callOptions: CodexCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - model: options.model, - systemPrompt: options.systemPrompt, - permissionMode: options.permissionMode, - onStream: options.onStream, - openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + if (!isInsideGitRepo(options.cwd)) return blockedResponse(name); + return callCodex(name, prompt, toCodexOptions(options)); + }, }; - - return callCodex(agentName, prompt, callOptions); - } - - async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise { - 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); } } diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index 4bfeb72..576744b 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -10,7 +10,7 @@ import { CodexProvider } from './codex.js'; import { MockProvider } from './mock.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. diff --git a/src/infra/providers/mock.ts b/src/infra/providers/mock.ts index 8850878..10ae1e5 100644 --- a/src/infra/providers/mock.ts +++ b/src/infra/providers/mock.ts @@ -4,27 +4,37 @@ import { callMock, callMockCustom, type MockCallOptions } from '../mock/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 { - async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise { - const callOptions: MockCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - onStream: options.onStream, + setup(config: AgentSetup): ProviderAgent { + if (config.claudeAgent) { + throw new Error('Claude Code agent calls are not supported by the Mock provider'); + } + 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 => + callMockCustom(name, prompt, systemPrompt, toMockOptions(options)), + }; + } + + return { + call: (prompt: string, options: ProviderCallOptions): Promise => + callMock(name, prompt, toMockOptions(options)), }; - - return callMock(agentName, prompt, callOptions); - } - - async callCustom(agentName: string, prompt: string, _systemPrompt: string, options: ProviderCallOptions): Promise { - const callOptions: MockCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - onStream: options.onStream, - }; - - return callMockCustom(agentName, prompt, _systemPrompt, callOptions); } } diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index 85fcc26..1764583 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -5,12 +5,23 @@ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/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 { cwd: string; sessionId?: string; model?: string; - systemPrompt?: string; allowedTools?: string[]; /** Maximum number of agentic turns */ maxTurns?: number; @@ -26,13 +37,14 @@ export interface ProviderCallOptions { openaiApiKey?: string; } -/** Provider interface - all providers must implement this */ -export interface Provider { - /** Call the provider with a prompt (using systemPrompt from options if provided) */ - call(agentName: string, prompt: string, options: ProviderCallOptions): Promise; +/** A configured agent ready to be called */ +export interface ProviderAgent { + call(prompt: string, options: ProviderCallOptions): Promise; +} - /** Call the provider with explicit system prompt */ - callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise; +/** Provider interface — creates configured agents from setup */ +export interface Provider { + setup(config: AgentSetup): ProviderAgent; } /** Provider type */ diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index 9a7dd6e..185bd13 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -66,15 +66,15 @@ export class TaskSummarizer { const model = options.model ?? globalConfig.model; const provider = getProvider(providerType); - const callOptions: SummarizeOptions & { systemPrompt: string; allowedTools: [] } = { - cwd: options.cwd, + const agent = provider.setup({ + name: 'summarizer', systemPrompt: loadTemplate('score_slug_system_prompt', 'en'), + }); + const response = await agent.call(taskName, { + cwd: options.cwd, + model, allowedTools: [], - }; - if (model) { - callOptions.model = model; - } - const response = await provider.call('summarizer', taskName, callOptions); + }); const slug = sanitizeSlug(response.content); log.info('Task name summarized', { original: taskName, slug });