fix: unify agent provider/model resolution and remove custom agent overrides
This commit is contained in:
parent
551299dbf8
commit
644c318295
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 固有のモデルに関する注意
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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' }),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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' }
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user