fix: unify agent provider/model resolution and remove custom agent overrides

This commit is contained in:
nrslib 2026-02-27 00:27:52 +09:00
parent 551299dbf8
commit 644c318295
15 changed files with 503 additions and 187 deletions

View File

@ -78,8 +78,6 @@ agents:
- Read - Read
- Glob - Glob
- Grep - Grep
provider: claude # Optional: claude, codex, or opencode
model: opus # Optional: model alias or full name
``` ```
### Agent Configuration Options ### Agent Configuration Options
@ -90,8 +88,6 @@ agents:
| `prompt_file` | Path to Markdown prompt file | | `prompt_file` | Path to Markdown prompt file |
| `prompt` | Inline prompt text (alternative to `prompt_file`) | | `prompt` | Inline prompt text (alternative to `prompt_file`) |
| `allowed_tools` | List of tools the agent can use | | `allowed_tools` | List of tools the agent can use |
| `provider` | Provider override: `claude`, `codex`, or `opencode` |
| `model` | Model override (alias or full name) |
### Available Tools ### Available Tools

View File

@ -223,9 +223,8 @@ codex_cli_path: /usr/local/bin/codex
各 movement で使用されるモデルは、次の優先順位(高い順)で解決されます。 各 movement で使用されるモデルは、次の優先順位(高い順)で解決されます。
1. **Piece movement の `model`** - piece YAML の movement 定義で指定 1. **Piece movement の `model`** - piece YAML の movement 定義で指定
2. **カスタムエージェントの `model`** - `.takt/agents.yaml` のエージェントレベルのモデル 2. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル
3. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル 3. **Provider デフォルト** - provider のビルトインデフォルトにフォールバックClaude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト)
4. **Provider デフォルト** - provider のビルトインデフォルトにフォールバックClaude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト)
### Provider 固有のモデルに関する注意 ### Provider 固有のモデルに関する注意

View File

@ -223,9 +223,8 @@ The path must be an absolute path to an executable file. `TAKT_CODEX_CLI_PATH` t
The model used for each movement is resolved with the following priority order (highest first): The model used for each movement is resolved with the following priority order (highest first):
1. **Piece movement `model`** - Specified in the movement definition in piece YAML 1. **Piece movement `model`** - Specified in the movement definition in piece YAML
2. **Custom agent `model`** - Agent-level model in `.takt/agents.yaml` 2. **Global config `model`** - Default model in `~/.takt/config.yaml`
3. **Global config `model`** - Default model in `~/.takt/config.yaml` 3. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default)
4. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default)
### Provider-specific Model Notes ### Provider-specific Model Notes

View File

@ -470,17 +470,6 @@ describe('CustomAgentConfigSchema', () => {
expect(result.claude_agent).toBe('architect'); expect(result.claude_agent).toBe('architect');
}); });
it('should accept agent with provider override', () => {
const config = {
name: 'my-agent',
prompt: 'You are a helpful assistant.',
provider: 'codex',
};
const result = CustomAgentConfigSchema.parse(config);
expect(result.provider).toBe('codex');
});
it('should reject agent without any prompt source', () => { it('should reject agent without any prompt source', () => {
const config = { const config = {
name: 'my-agent', name: 'my-agent',

View File

@ -6,7 +6,6 @@ import { describe, it, expect } from 'vitest';
import { import {
GlobalConfigSchema, GlobalConfigSchema,
ProjectConfigSchema, ProjectConfigSchema,
CustomAgentConfigSchema,
PieceMovementRawSchema, PieceMovementRawSchema,
ParallelSubMovementRawSchema, ParallelSubMovementRawSchema,
} from '../core/models/index.js'; } from '../core/models/index.js';
@ -64,15 +63,6 @@ describe('Schemas accept opencode provider', () => {
expect(() => ProjectConfigSchema.parse({ submodules: 'libs' })).toThrow(); expect(() => ProjectConfigSchema.parse({ submodules: 'libs' })).toThrow();
}); });
it('should accept opencode in CustomAgentConfigSchema', () => {
const result = CustomAgentConfigSchema.parse({
name: 'test',
prompt: 'You are a test agent',
provider: 'opencode',
});
expect(result.provider).toBe('opencode');
});
it('should accept opencode in PieceMovementRawSchema', () => { it('should accept opencode in PieceMovementRawSchema', () => {
const result = PieceMovementRawSchema.parse({ const result = PieceMovementRawSchema.parse({
name: 'test-movement', name: 'test-movement',

View File

@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const { const {
getProviderMock, getProviderMock,
loadConfigMock,
loadCustomAgentsMock, loadCustomAgentsMock,
loadAgentPromptMock, loadAgentPromptMock,
loadProjectConfigMock,
loadGlobalConfigMock,
loadTemplateMock, loadTemplateMock,
providerSetupMock, providerSetupMock,
providerCallMock, providerCallMock,
@ -14,9 +15,10 @@ const {
return { return {
getProviderMock: vi.fn(() => ({ setup: providerSetup })), getProviderMock: vi.fn(() => ({ setup: providerSetup })),
loadConfigMock: vi.fn(),
loadCustomAgentsMock: vi.fn(), loadCustomAgentsMock: vi.fn(),
loadAgentPromptMock: vi.fn(), loadAgentPromptMock: vi.fn(),
loadProjectConfigMock: vi.fn(),
loadGlobalConfigMock: vi.fn(),
loadTemplateMock: vi.fn(), loadTemplateMock: vi.fn(),
providerSetupMock: providerSetup, providerSetupMock: providerSetup,
providerCallMock: providerCall, providerCallMock: providerCall,
@ -28,21 +30,10 @@ vi.mock('../infra/providers/index.js', () => ({
})); }));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadConfig: loadConfigMock, loadProjectConfig: loadProjectConfigMock,
loadGlobalConfig: loadGlobalConfigMock,
loadCustomAgents: loadCustomAgentsMock, loadCustomAgents: loadCustomAgentsMock,
loadAgentPrompt: loadAgentPromptMock, loadAgentPrompt: loadAgentPromptMock,
resolveConfigValues: (_projectDir: string, keys: readonly string[]) => {
const loaded = loadConfigMock() as Record<string, unknown>;
const global = (loaded.global ?? {}) as Record<string, unknown>;
const project = (loaded.project ?? {}) as Record<string, unknown>;
const provider = (project.provider ?? global.provider ?? 'claude') as string;
const config: Record<string, unknown> = { ...global, ...project, provider, piece: project.piece ?? 'default', verbose: false };
const result: Record<string, unknown> = {};
for (const key of keys) {
result[key] = config[key];
}
return result;
},
})); }));
vi.mock('../shared/prompts/index.js', () => ({ vi.mock('../shared/prompts/index.js', () => ({
@ -56,120 +47,169 @@ describe('option resolution order', () => {
vi.clearAllMocks(); vi.clearAllMocks();
providerCallMock.mockResolvedValue({ content: 'ok' }); providerCallMock.mockResolvedValue({ content: 'ok' });
loadConfigMock.mockReturnValue({ global: {}, project: {} }); loadProjectConfigMock.mockReturnValue({});
loadGlobalConfigMock.mockReturnValue({
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
});
loadCustomAgentsMock.mockReturnValue(new Map()); loadCustomAgentsMock.mockReturnValue(new Map());
loadAgentPromptMock.mockReturnValue('prompt'); loadAgentPromptMock.mockReturnValue('prompt');
loadTemplateMock.mockReturnValue('template'); loadTemplateMock.mockReturnValue('template');
}); });
it('should resolve provider in order: CLI > stepProvider > Config(project??global) > default', async () => { it('should resolve provider in order: CLI > stepProvider > local config > global config', async () => {
// Given loadProjectConfigMock.mockReturnValue({ provider: 'opencode' });
loadConfigMock.mockReturnValue({ loadGlobalConfigMock.mockReturnValue({
project: { provider: 'opencode' }, provider: 'mock',
global: { provider: 'mock' }, language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
}); });
// When: CLI provider が指定される
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
cwd: '/repo', cwd: '/repo',
provider: 'codex', provider: 'codex',
stepProvider: 'claude', stepProvider: 'claude',
}); });
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('codex'); expect(getProviderMock).toHaveBeenLastCalledWith('codex');
// When: CLI 指定なしstepProvider が優先される)
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
cwd: '/repo', cwd: '/repo',
stepProvider: 'claude', stepProvider: 'claude',
}); });
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('claude'); expect(getProviderMock).toHaveBeenLastCalledWith('claude');
// When: project なし → resolveConfigValues は global.provider を返す(フラットマージ) loadProjectConfigMock.mockReturnValue({});
loadConfigMock.mockReturnValue({ loadGlobalConfigMock.mockReturnValue({
project: {}, provider: 'mock',
global: { provider: 'mock' }, language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
}); });
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
cwd: '/repo', cwd: '/repo',
stepProvider: 'claude', stepProvider: 'claude',
}); });
// Then: stepProvider が global fallback より優先される
expect(getProviderMock).toHaveBeenLastCalledWith('claude'); expect(getProviderMock).toHaveBeenLastCalledWith('claude');
// When: stepProvider もなし → 同様に global.provider
await runAgent(undefined, 'task', { cwd: '/repo' }); await runAgent(undefined, 'task', { cwd: '/repo' });
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('mock'); expect(getProviderMock).toHaveBeenLastCalledWith('mock');
}); });
it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => { it('should apply persona provider override before local/global config', async () => {
// Given loadProjectConfigMock.mockReturnValue({ provider: 'opencode' });
loadConfigMock.mockReturnValue({ loadGlobalConfigMock.mockReturnValue({
project: { provider: 'claude' }, provider: 'mock',
global: { provider: 'claude', model: 'global-model' }, personaProviders: {
coder: { provider: 'claude' },
},
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
}); });
// When: CLI model あり await runAgent('coder', 'task', {
await runAgent(undefined, 'task', { cwd: '/repo',
});
expect(getProviderMock).toHaveBeenLastCalledWith('claude');
});
it('should resolve model in order: CLI > persona > step > local > global', async () => {
loadProjectConfigMock.mockReturnValue({
provider: 'claude',
model: 'local-model',
});
loadGlobalConfigMock.mockReturnValue({
provider: 'claude',
model: 'global-model',
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
personaProviders: {
coder: { model: 'persona-model' },
},
});
await runAgent('coder', 'task', {
cwd: '/repo', cwd: '/repo',
model: 'cli-model', model: 'cli-model',
stepModel: 'step-model', stepModel: 'step-model',
}); });
// Then
expect(providerCallMock).toHaveBeenLastCalledWith( expect(providerCallMock).toHaveBeenLastCalledWith(
'task', 'task',
expect.objectContaining({ model: 'cli-model' }), expect.objectContaining({ model: 'cli-model' }),
); );
// When: CLI model なし
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
cwd: '/repo', cwd: '/repo',
stepModel: 'step-model', stepModel: 'step-model',
stepProvider: 'claude',
}); });
// Then
expect(providerCallMock).toHaveBeenLastCalledWith( expect(providerCallMock).toHaveBeenLastCalledWith(
'task', 'task',
expect.objectContaining({ model: 'step-model' }), expect.objectContaining({ model: 'step-model' }),
); );
// When: stepModel なし await runAgent('coder', 'task', {
await runAgent(undefined, 'task', { cwd: '/repo' }); cwd: '/repo',
stepProvider: 'claude',
});
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ model: 'persona-model' }),
);
loadGlobalConfigMock.mockReturnValue({
provider: 'codex',
model: 'global-model',
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
});
loadProjectConfigMock.mockReturnValue({
provider: 'codex',
});
await runAgent(undefined, 'task', {
cwd: '/repo',
stepProvider: 'codex',
});
// Then
expect(providerCallMock).toHaveBeenLastCalledWith( expect(providerCallMock).toHaveBeenLastCalledWith(
'task', 'task',
expect.objectContaining({ model: 'global-model' }), expect.objectContaining({ model: 'global-model' }),
); );
}); });
it('should ignore global model when resolved provider does not match config provider', async () => { it('should ignore local/global model if resolved provider is not matching', async () => {
// Given: CLI provider overrides config provider, causing mismatch with config.model loadProjectConfigMock.mockReturnValue({
loadConfigMock.mockReturnValue({ provider: 'claude',
project: {}, model: 'local-model',
global: { provider: 'claude', model: 'global-model' }, });
loadGlobalConfigMock.mockReturnValue({
provider: 'mock',
model: 'global-model',
language: 'en',
concurrency: 1,
taskPollIntervalMs: 500,
}); });
// When: CLI provider='codex' overrides config provider='claude' await runAgent(undefined, 'task', {
// resolveModel compares config.provider ('claude') with resolvedProvider ('codex') → mismatch → model ignored cwd: '/repo',
await runAgent(undefined, 'task', { cwd: '/repo', provider: 'codex' }); stepProvider: 'opencode',
});
// Then
expect(providerCallMock).toHaveBeenLastCalledWith( expect(providerCallMock).toHaveBeenLastCalledWith(
'task', 'task',
expect.objectContaining({ model: undefined }), expect.objectContaining({ model: undefined }),
); );
}); });
it('should use providerOptions from piece(step) only', async () => { it('should use providerOptions from piece/step only', async () => {
// Given
const stepProviderOptions = { const stepProviderOptions = {
claude: { claude: {
sandbox: { sandbox: {
@ -178,55 +218,37 @@ describe('option resolution order', () => {
}, },
}; };
loadConfigMock.mockReturnValue({
project: {
provider: 'claude',
},
global: {
provider: 'claude',
providerOptions: {
claude: { sandbox: { allowUnsandboxedCommands: true } },
},
},
});
// When
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
cwd: '/repo', cwd: '/repo',
provider: 'claude', provider: 'claude',
providerOptions: stepProviderOptions, providerOptions: stepProviderOptions,
}); });
// Then
expect(providerCallMock).toHaveBeenLastCalledWith( expect(providerCallMock).toHaveBeenLastCalledWith(
'task', 'task',
expect.objectContaining({ providerOptions: stepProviderOptions }), expect.objectContaining({ providerOptions: stepProviderOptions }),
); );
}); });
it('should use custom agent model and prompt when higher-priority values are absent', async () => { it('should ignore custom agent provider/model overrides', async () => {
// Given: custom agent with provider/model, but no CLI/config override loadProjectConfigMock.mockReturnValue({ provider: 'claude', model: 'project-model' });
// Note: resolveConfigValues returns provider='claude' by default (loadConfig merges project ?? global ?? 'claude'), loadGlobalConfigMock.mockReturnValue({
// so agentConfig.provider is not reached in resolveProvider (config.provider is always truthy). provider: 'mock',
// However, custom agent model IS used because resolveModel checks agentConfig.model before config. language: 'en',
const customAgents = new Map([ concurrency: 1,
['custom', { name: 'custom', prompt: 'agent prompt', provider: 'opencode', model: 'agent-model' }], taskPollIntervalMs: 500,
]); });
loadCustomAgentsMock.mockReturnValue(customAgents);
loadCustomAgentsMock.mockReturnValue(new Map([
['custom', { name: 'custom', prompt: 'agent prompt' }],
]));
// When
await runAgent('custom', 'task', { cwd: '/repo' }); await runAgent('custom', 'task', { cwd: '/repo' });
// Then: provider falls back to config default ('claude'), not agentConfig.provider
expect(getProviderMock).toHaveBeenLastCalledWith('claude'); expect(getProviderMock).toHaveBeenLastCalledWith('claude');
// Agent model is used (resolved before config.model in resolveModel)
expect(providerCallMock).toHaveBeenLastCalledWith( expect(providerCallMock).toHaveBeenLastCalledWith(
'task', 'task',
expect.objectContaining({ model: 'agent-model' }), expect.objectContaining({ model: 'project-model' }),
);
// Agent prompt is still used
expect(providerSetupMock).toHaveBeenLastCalledWith(
expect.objectContaining({ systemPrompt: 'prompt' }),
); );
}); });
}); });

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveMovementProviderModel, resolveProviderModelCandidates } from '../core/piece/provider-resolution.js'; import {
resolveAgentProviderModel,
resolveMovementProviderModel,
resolveProviderModelCandidates,
} from '../core/piece/provider-resolution.js';
describe('resolveProviderModelCandidates', () => { describe('resolveProviderModelCandidates', () => {
it('should resolve first defined provider and model independently', () => { it('should resolve first defined provider and model independently', () => {
@ -26,118 +30,313 @@ describe('resolveProviderModelCandidates', () => {
describe('resolveMovementProviderModel', () => { describe('resolveMovementProviderModel', () => {
it('should prefer personaProviders.provider over step.provider when both are defined', () => { it('should prefer personaProviders.provider over step.provider when both are defined', () => {
// Given: step.provider と personaProviders.provider が両方指定されている
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' }, step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
provider: 'claude', provider: 'claude',
personaProviders: { coder: { provider: 'opencode' } }, personaProviders: { coder: { provider: 'opencode' } },
}); });
// When: provider/model を解決する
// Then: personaProviders.provider が step.provider を上書きする
expect(result.provider).toBe('opencode'); expect(result.provider).toBe('opencode');
}); });
it('should use personaProviders.provider when step.provider is undefined', () => { it('should use personaProviders.provider when step.provider is undefined', () => {
// Given: step.provider が未定義で personaProviders に対応がある
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' }, step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
provider: 'claude', provider: 'claude',
personaProviders: { reviewer: { provider: 'opencode' } }, personaProviders: { reviewer: { provider: 'opencode' } },
}); });
// When: provider/model を解決する
// Then: personaProviders の provider が使われる
expect(result.provider).toBe('opencode'); expect(result.provider).toBe('opencode');
}); });
it('should fallback to input.provider when persona mapping is missing', () => { it('should fallback to input.provider when persona mapping is missing', () => {
// Given: step.provider 未定義かつ persona マッピングが存在しない
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' }, step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
provider: 'mock', provider: 'mock',
personaProviders: { reviewer: { provider: 'codex' } }, personaProviders: { reviewer: { provider: 'codex' } },
}); });
// When: provider/model を解決する
// Then: input.provider が使われる
expect(result.provider).toBe('mock'); expect(result.provider).toBe('mock');
}); });
it('should return undefined provider when all provider candidates are missing', () => { it('should return undefined provider when all provider candidates are missing', () => {
// Given: provider の候補がすべて未定義
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'none' }, step: { provider: undefined, model: undefined, personaDisplayName: 'none' },
provider: undefined, provider: undefined,
personaProviders: undefined, personaProviders: undefined,
}); });
// When: provider/model を解決する
// Then: provider は undefined になる
expect(result.provider).toBeUndefined(); expect(result.provider).toBeUndefined();
}); });
it('should prefer personaProviders.model over step.model and input.model', () => { it('should prefer personaProviders.model over step.model and input.model', () => {
// Given: step.model と personaProviders.model と input.model が指定されている
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' }, step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
model: 'input-model', model: 'input-model',
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } }, personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
}); });
// When: provider/model を解決する
// Then: personaProviders.model が step.model を上書きする
expect(result.model).toBe('persona-model'); expect(result.model).toBe('persona-model');
}); });
it('should use personaProviders.model when step.model is undefined', () => { it('should use personaProviders.model when step.model is undefined', () => {
// Given: step.model が未定義で personaProviders.model が指定されている
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' }, step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
model: 'input-model', model: 'input-model',
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } }, personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
}); });
// When: provider/model を解決する
// Then: personaProviders.model が使われる
expect(result.model).toBe('persona-model'); expect(result.model).toBe('persona-model');
}); });
it('should fallback to input.model when step.model and personaProviders.model are undefined', () => { it('should fallback to input.model when step.model and personaProviders.model are undefined', () => {
// Given: step.model と personaProviders.model が未定義で input.model が指定されている
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' }, step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
model: 'input-model', model: 'input-model',
personaProviders: { coder: { provider: 'codex' } }, personaProviders: { coder: { provider: 'codex' } },
}); });
// When: provider/model を解決する
// Then: input.model が使われる
expect(result.model).toBe('input-model'); expect(result.model).toBe('input-model');
}); });
it('should return undefined model when all model candidates are missing', () => { it('should return undefined model when all model candidates are missing', () => {
// Given: model の候補がすべて未定義
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' }, step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
model: undefined, model: undefined,
personaProviders: { coder: { provider: 'codex' } }, personaProviders: { coder: { provider: 'codex' } },
}); });
// Then: model は undefined になる
expect(result.model).toBeUndefined(); expect(result.model).toBeUndefined();
}); });
it('should resolve provider from personaProviders entry with only model specified', () => { it('should resolve provider from personaProviders entry with only model specified', () => {
// Given: personaProviders エントリに provider が指定されていないmodel のみ)
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' }, step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
provider: 'claude', provider: 'claude',
personaProviders: { coder: { model: 'o3-mini' } }, personaProviders: { coder: { model: 'o3-mini' } },
}); });
// Then: provider は input.provider、model は personaProviders.model になる
expect(result.provider).toBe('claude'); expect(result.provider).toBe('claude');
expect(result.model).toBe('o3-mini'); expect(result.model).toBe('o3-mini');
}); });
}); });
describe('resolveAgentProviderModel', () => {
it('should resolve provider in order: CLI > persona > movement > local > global', () => {
const result = resolveAgentProviderModel({
cliProvider: 'opencode',
stepProvider: 'claude',
localProvider: 'codex',
globalProvider: 'claude',
personaProviders: { coder: { provider: 'mock' } },
personaDisplayName: 'coder',
});
expect(result.provider).toBe('opencode');
});
it('should use persona override when no CLI provider is set', () => {
const result = resolveAgentProviderModel({
stepProvider: 'claude',
localProvider: 'codex',
globalProvider: 'claude',
personaProviders: { coder: { provider: 'opencode', model: 'persona-model' } },
personaDisplayName: 'coder',
});
expect(result.provider).toBe('opencode');
expect(result.model).toBe('persona-model');
});
it('should fall back to movement provider when persona override is not configured', () => {
const result = resolveAgentProviderModel({
stepProvider: 'claude',
localProvider: 'codex',
globalProvider: 'claude',
personaProviders: { reviewer: { provider: 'mock', model: 'o3-mini' } },
personaDisplayName: 'coder',
});
expect(result.provider).toBe('claude');
});
it('should prefer local config provider/model over global config for same provider', () => {
const result = resolveAgentProviderModel({
localProvider: 'codex',
localModel: 'local-model',
globalProvider: 'codex',
globalModel: 'global-model',
});
expect(result.provider).toBe('codex');
expect(result.model).toBe('local-model');
});
it('should prefer global config when local config is not set', () => {
const result = resolveAgentProviderModel({
localProvider: undefined,
globalProvider: 'claude',
globalModel: 'global-model',
});
expect(result.provider).toBe('claude');
expect(result.model).toBe('global-model');
});
it('should resolve model order: CLI > persona > movement > config candidate matching provider', () => {
const result = resolveAgentProviderModel({
cliModel: 'cli-model',
stepModel: 'movement-model',
localProvider: 'claude',
localModel: 'local-model',
globalProvider: 'codex',
globalModel: 'global-model',
cliProvider: 'codex',
personaProviders: { coder: { model: 'persona-model' } },
personaDisplayName: 'coder',
});
expect(result.provider).toBe('codex');
expect(result.model).toBe('cli-model');
});
it('should use movement model when persona model is absent', () => {
const result = resolveAgentProviderModel({
stepModel: 'movement-model',
localProvider: 'claude',
localModel: 'local-model',
globalProvider: 'codex',
globalModel: 'global-model',
personaProviders: { coder: { provider: 'opencode' } },
personaDisplayName: 'coder',
});
expect(result.provider).toBe('opencode');
expect(result.model).toBe('movement-model');
});
it('should apply local/ global model only when provider matches resolved provider', () => {
const result = resolveAgentProviderModel({
localProvider: 'claude',
localModel: 'local-model',
globalProvider: 'codex',
globalModel: 'global-model',
stepProvider: 'codex',
});
expect(result.provider).toBe('codex');
expect(result.model).toBe('global-model');
});
it('should ignore local and global model when provider does not match', () => {
const result = resolveAgentProviderModel({
localProvider: 'codex',
localModel: 'local-model',
globalProvider: 'claude',
globalModel: 'global-model',
stepProvider: 'opencode',
});
expect(result.provider).toBe('opencode');
expect(result.model).toBeUndefined();
});
it('should combine persona and movement overrides in one run', () => {
const result = resolveAgentProviderModel({
cliProvider: 'codex',
stepProvider: 'claude',
stepModel: 'movement-model',
localProvider: 'claude',
localModel: 'local-model',
globalProvider: 'mock',
globalModel: 'global-model',
cliModel: 'cli-model',
personaProviders: {
coder: {
provider: 'mock',
model: 'persona-model',
},
},
personaDisplayName: 'coder',
});
expect(result.provider).toBe('codex');
expect(result.model).toBe('cli-model');
});
it('should apply full priority chain when all layers are present', () => {
const result = resolveAgentProviderModel({
cliProvider: 'codex',
cliModel: 'cli-model',
personaProviders: {
reviewer: {
provider: 'mock',
model: 'persona-model',
},
},
personaDisplayName: 'reviewer',
stepProvider: 'claude',
stepModel: 'step-model',
localProvider: 'opencode',
localModel: 'local-model',
globalProvider: 'claude',
globalModel: 'global-model',
});
expect(result.provider).toBe('codex');
expect(result.model).toBe('cli-model');
});
it('should apply full priority chain without cli overrides', () => {
const result = resolveAgentProviderModel({
personaProviders: {
reviewer: {
provider: 'mock',
model: 'persona-model',
},
},
personaDisplayName: 'reviewer',
stepProvider: 'claude',
stepModel: 'step-model',
localProvider: 'opencode',
localModel: 'local-model',
globalProvider: 'claude',
globalModel: 'global-model',
});
expect(result.provider).toBe('mock');
expect(result.model).toBe('persona-model');
});
it('should keep model and provider priorities consistent for fallback path', () => {
const result = resolveAgentProviderModel({
stepProvider: 'claude',
localProvider: 'codex',
localModel: 'local-model',
globalProvider: 'claude',
globalModel: 'global-model',
});
expect(result.provider).toBe('claude');
expect(result.model).toBe('global-model');
});
it('should keep model fallback after persona-only model when step model is absent', () => {
const result = resolveAgentProviderModel({
personaProviders: {
reviewer: {
model: 'persona-model',
},
},
personaDisplayName: 'reviewer',
stepProvider: 'claude',
localProvider: 'codex',
localModel: 'local-model',
globalProvider: 'codex',
globalModel: 'global-model',
});
expect(result.provider).toBe('claude');
expect(result.model).toBe('persona-model');
});
});

View File

@ -67,7 +67,7 @@ vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => key), getLabel: vi.fn((key: string) => key),
})); }));
import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js'; import { executeAndCompleteTask, executeTask } from '../features/tasks/execute/taskExecution.js';
const createTask = (name: string): TaskInfo => ({ const createTask = (name: string): TaskInfo => ({
name, name,
@ -151,6 +151,54 @@ describe('executeAndCompleteTask', () => {
expect(pieceExecutionOptions?.providerOptionsSource).toBe('project'); expect(pieceExecutionOptions?.providerOptionsSource).toBe('project');
}); });
it('should not pass config provider/model to executePiece when agent overrides are absent', async () => {
// Given: project config contains provider/model, but overrides are omitted.
const task = createTask('task-with-defaults');
// When
await executeTask({
task: task.content,
cwd: '/project',
projectCwd: '/project',
pieceIdentifier: 'default',
});
// Then: piece options should not force provider/model from taskExecution layer
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
provider?: string;
model?: string;
};
expect(pieceExecutionOptions?.provider).toBeUndefined();
expect(pieceExecutionOptions?.model).toBeUndefined();
});
it('should pass agent overrides to executePiece when provided', async () => {
// Given: overrides explicitly specified by caller.
const task = createTask('task-with-overrides');
// When
await executeTask({
task: task.content,
cwd: '/project',
projectCwd: '/project',
pieceIdentifier: 'default',
agentOverrides: {
provider: 'codex',
model: 'gpt-5.3-codex',
},
});
// Then
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
provider?: string;
model?: string;
};
expect(pieceExecutionOptions?.provider).toBe('codex');
expect(pieceExecutionOptions?.model).toBe('gpt-5.3-codex');
});
it('should mark task as failed when PR creation fails', async () => { it('should mark task as failed when PR creation fails', async () => {
// Given: worktree mode with autoPr enabled, PR creation fails // Given: worktree mode with autoPr enabled, PR creation fails
const task = createTask('task-with-pr-failure'); const task = createTask('task-with-pr-failure');

View File

@ -4,10 +4,10 @@
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 { loadCustomAgents, loadAgentPrompt, resolveConfigValues } 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';
import { resolveProviderModelCandidates } from '../core/piece/provider-resolution.js'; import { resolveAgentProviderModel } from '../core/piece/provider-resolution.js';
import { createLogger } from '../shared/utils/index.js'; import { createLogger } from '../shared/utils/index.js';
import { loadTemplate } from '../shared/prompts/index.js'; import { loadTemplate } from '../shared/prompts/index.js';
import type { RunAgentOptions } from './types.js'; import type { RunAgentOptions } from './types.js';
@ -25,33 +25,31 @@ const log = createLogger('runner');
export class AgentRunner { export class AgentRunner {
private static resolveProviderAndModel( private static resolveProviderAndModel(
cwd: string, cwd: string,
personaDisplayName: string | undefined,
options?: RunAgentOptions, options?: RunAgentOptions,
agentConfig?: CustomAgentConfig,
): { provider: ProviderType; model: string | undefined } { ): { provider: ProviderType; model: string | undefined } {
const config = resolveConfigValues(cwd, ['provider', 'model']); const localConfig = loadProjectConfig(cwd);
const resolvedProvider = resolveProviderModelCandidates([ const globalConfig = loadGlobalConfig();
{ provider: options?.provider },
{ provider: options?.stepProvider }, const resolvedProviderModel = resolveAgentProviderModel({
{ provider: config.provider }, personaDisplayName,
{ provider: agentConfig?.provider }, cliProvider: options?.provider,
]).provider; cliModel: options?.model,
stepProvider: options?.stepProvider,
stepModel: options?.stepModel,
personaProviders: globalConfig.personaProviders,
localProvider: localConfig.provider,
localModel: localConfig.model,
globalProvider: globalConfig.provider,
globalModel: globalConfig.model,
});
const resolvedProvider = resolvedProviderModel.provider;
if (!resolvedProvider) { if (!resolvedProvider) {
throw new Error('No provider configured. Set "provider" in ~/.takt/config.yaml'); throw new Error('No provider configured. Set "provider" in ~/.takt/config.yaml');
} }
const configModel = config.provider === resolvedProvider
? config.model
: undefined;
const resolvedModel = resolveProviderModelCandidates([
{ model: options?.model },
{ model: options?.stepModel },
{ model: agentConfig?.model },
{ model: configModel },
]).model;
return { return {
provider: resolvedProvider, provider: resolvedProvider,
model: resolvedModel, model: resolvedProviderModel.model,
}; };
} }
@ -112,7 +110,7 @@ export class AgentRunner {
task: string, task: string,
options: RunAgentOptions, options: RunAgentOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const resolved = AgentRunner.resolveProviderAndModel(options.cwd, options, agentConfig); const resolved = AgentRunner.resolveProviderAndModel(options.cwd, agentConfig.name, options);
const providerType = resolved.provider; const providerType = resolved.provider;
const provider = getProvider(providerType); const provider = getProvider(providerType);
@ -145,7 +143,7 @@ export class AgentRunner {
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
}); });
const resolved = AgentRunner.resolveProviderAndModel(options.cwd, options); const resolved = AgentRunner.resolveProviderAndModel(options.cwd, personaName, options);
const providerType = resolved.provider; const providerType = resolved.provider;
const provider = getProvider(providerType); const provider = getProvider(providerType);
const callOptions = AgentRunner.buildCallOptions(resolved.model, options); const callOptions = AgentRunner.buildCallOptions(resolved.model, options);

View File

@ -18,8 +18,6 @@ export interface CustomAgentConfig {
allowedTools?: string[]; allowedTools?: string[];
claudeAgent?: string; claudeAgent?: string;
claudeSkill?: string; claudeSkill?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
model?: string;
} }
/** Observability configuration for runtime event logs */ /** Observability configuration for runtime event logs */

View File

@ -380,8 +380,6 @@ export const CustomAgentConfigSchema = z.object({
allowed_tools: z.array(z.string()).optional(), allowed_tools: z.array(z.string()).optional(),
claude_agent: z.string().optional(), claude_agent: z.string().optional(),
claude_skill: z.string().optional(), claude_skill: z.string().optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
}).refine( }).refine(
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill, (data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
{ message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' } { message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' }

View File

@ -130,7 +130,31 @@ export class LineTimeSliceBuffer {
} }
private isBoundary(ch: string): boolean { private isBoundary(ch: string): boolean {
return /\s|[,.!?;:\[\]{}]/u.test(ch); const boundaryChars = new Set([
' ',
'\n',
'\t',
',',
'.',
'!',
'?',
';',
':',
'、',
'。',
'',
'',
'',
'',
'',
'',
'[',
']',
'{',
'}',
]);
return boundaryChars.has(ch);
} }
private clearTimer(key: string): void { private clearTimer(key: string): void {

View File

@ -19,6 +19,11 @@ export interface ProviderModelCandidate {
model?: string; model?: string;
} }
interface ModelProviderCandidate {
model?: string;
provider?: ProviderType;
}
export function resolveProviderModelCandidates( export function resolveProviderModelCandidates(
candidates: readonly ProviderModelCandidate[], candidates: readonly ProviderModelCandidate[],
): MovementProviderModelOutput { ): MovementProviderModelOutput {
@ -40,6 +45,61 @@ export function resolveProviderModelCandidates(
return { provider, model }; return { provider, model };
} }
export interface AgentProviderModelInput {
cliProvider?: ProviderType;
cliModel?: string;
personaProviders?: Record<string, PersonaProviderEntry>;
personaDisplayName?: string;
stepProvider?: ProviderType;
stepModel?: string;
localProvider?: ProviderType;
localModel?: string;
globalProvider?: ProviderType;
globalModel?: string;
}
export interface AgentProviderModelOutput {
provider?: ProviderType;
model?: string;
}
function resolveModelFromCandidates(
candidates: readonly ModelProviderCandidate[],
resolvedProvider: ProviderType | undefined,
): string | undefined {
for (const candidate of candidates) {
const { model, provider } = candidate;
if (model === undefined) {
continue;
}
if (provider !== undefined && provider !== resolvedProvider) {
continue;
}
return model;
}
return undefined;
}
export function resolveAgentProviderModel(input: AgentProviderModelInput): AgentProviderModelOutput {
const personaEntry = input.personaProviders?.[input.personaDisplayName ?? ''];
const provider = resolveProviderModelCandidates([
{ provider: input.cliProvider },
{ provider: personaEntry?.provider },
{ provider: input.stepProvider },
{ provider: input.localProvider },
{ provider: input.globalProvider },
]).provider;
const model = resolveModelFromCandidates([
{ model: input.cliModel },
{ model: personaEntry?.model },
{ model: input.stepModel },
{ model: input.localModel, provider: input.localProvider },
{ model: input.globalModel, provider: input.globalProvider },
], provider);
return { provider, model };
}
export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput { export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput {
const personaEntry = input.personaProviders?.[input.step.personaDisplayName]; const personaEntry = input.personaProviders?.[input.step.personaDisplayName];
const provider = resolveProviderModelCandidates([ const provider = resolveProviderModelCandidates([

View File

@ -62,19 +62,13 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
movements: pieceConfig.movements.map((s: { name: string }) => s.name), movements: pieceConfig.movements.map((s: { name: string }) => s.name),
}); });
const config = resolvePieceConfigValues(projectCwd, [ const config = resolvePieceConfigValues(projectCwd, ['language', 'personaProviders', 'providerProfiles']);
'language',
'provider',
'model',
'personaProviders',
'providerProfiles',
]);
const providerOptions = resolveConfigValueWithSource(projectCwd, 'providerOptions'); const providerOptions = resolveConfigValueWithSource(projectCwd, 'providerOptions');
return await executePiece(pieceConfig, task, cwd, { return await executePiece(pieceConfig, task, cwd, {
projectCwd, projectCwd,
language: config.language, language: config.language,
provider: agentOverrides?.provider ?? config.provider, provider: agentOverrides?.provider,
model: agentOverrides?.model ?? config.model, model: agentOverrides?.model,
providerOptions: providerOptions.value, providerOptions: providerOptions.value,
providerOptionsSource: providerOptions.source === 'piece' ? 'global' : providerOptions.source, providerOptionsSource: providerOptions.source === 'piece' ? 'global' : providerOptions.source,
personaProviders: config.personaProviders, personaProviders: config.personaProviders,

View File

@ -12,6 +12,8 @@ export interface ProjectLocalConfig {
piece?: string; piece?: string;
/** Provider selection for agent runtime */ /** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'mock';
/** Model selection for agent runtime */
model?: string;
/** Auto-create PR after worktree execution */ /** Auto-create PR after worktree execution */
autoPr?: boolean; autoPr?: boolean;
/** Create PR as draft */ /** Create PR as draft */