feat(KB-025): add per-task model overrides for executor and validator
- Add model override fields to Task type and TaskStore (modelProvider, modelId, validatorModelProvider, validatorModelId) - Extend PATCH /api/tasks/:id endpoint with validation for model fields - Create ModelSelectorTab component with provider/model dropdowns and tests - Integrate Model tab into TaskDetailModal for per-task model configuration - Update executor to use per-task model overrides when both provider and modelId are set - Update reviewer to use per-task validator model overrides in reviewStep - Document per-task model selection feature in AGENTS.md settings section
This commit is contained in:
@@ -2852,3 +2852,176 @@ describe("TaskExecutor usage limit detection", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Per-task model overrides", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("uses per-task model overrides when both provider and modelId are set", async () => {
|
||||
const store = createMockStore();
|
||||
const capturedOptions: any[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||
capturedOptions.push(opts);
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Override getTask to return task with model overrides
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
// Should use per-task model overrides
|
||||
expect(capturedOptions[0].defaultProvider).toBe("anthropic");
|
||||
expect(capturedOptions[0].defaultModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("falls back to global settings when per-task model is not fully specified", async () => {
|
||||
const store = createMockStore();
|
||||
const capturedOptions: any[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||
capturedOptions.push(opts);
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: undefined,
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
// No modelProvider/modelId set
|
||||
});
|
||||
|
||||
// Should use global settings (not task overrides)
|
||||
expect(capturedOptions[0].defaultProvider).toBe("openai");
|
||||
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("falls back to global settings when only modelProvider is set (missing modelId)", async () => {
|
||||
const store = createMockStore();
|
||||
const capturedOptions: any[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||
capturedOptions.push(opts);
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: undefined,
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Override getTask to return task with only modelProvider set
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
modelProvider: "anthropic",
|
||||
// modelId is missing
|
||||
});
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
modelProvider: "anthropic",
|
||||
// modelId is missing
|
||||
});
|
||||
|
||||
// Should fall back to global settings since modelId is not set
|
||||
expect(capturedOptions[0].defaultProvider).toBe("openai");
|
||||
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,7 +396,7 @@ export class TaskExecutor {
|
||||
this.createTaskCreateTool(),
|
||||
this.createTaskAddDepTool(task.id),
|
||||
this.createTaskDoneTool(task.id, () => { taskDone = true; }),
|
||||
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints),
|
||||
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail),
|
||||
];
|
||||
|
||||
const agentLogger = new AgentLogger({
|
||||
@@ -408,6 +408,15 @@ export class TaskExecutor {
|
||||
});
|
||||
|
||||
const agentWork = async () => {
|
||||
// Resolve model settings: use per-task overrides if both provider and modelId are set,
|
||||
// otherwise fall back to global settings
|
||||
const executorProvider = detail.modelProvider && detail.modelId
|
||||
? detail.modelProvider
|
||||
: settings.defaultProvider;
|
||||
const executorModelId = detail.modelProvider && detail.modelId
|
||||
? detail.modelId
|
||||
: settings.defaultModelId;
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||
@@ -417,8 +426,8 @@ export class TaskExecutor {
|
||||
onThinking: agentLogger.onThinking,
|
||||
onToolStart: agentLogger.onToolStart,
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultProvider: executorProvider,
|
||||
defaultModelId: executorModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
@@ -732,6 +741,7 @@ export class TaskExecutor {
|
||||
codeReviewVerdicts: Map<number, ReviewVerdict>,
|
||||
sessionRef: { current: AgentSession | null },
|
||||
stepCheckpoints: Map<number, string>,
|
||||
detail: TaskDetail,
|
||||
): ToolDefinition {
|
||||
const store = this.store;
|
||||
const options = this.options;
|
||||
@@ -761,6 +771,8 @@ export class TaskExecutor {
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
validatorModelProvider: detail.validatorModelProvider,
|
||||
validatorModelId: detail.validatorModelId,
|
||||
store,
|
||||
taskId,
|
||||
},
|
||||
|
||||
@@ -245,3 +245,95 @@ describe("reviewStep — exhausted-retry error detection", () => {
|
||||
expect(result.verdict).toBe("APPROVE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewStep — validator model overrides", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses validatorModelProvider and validatorModelId when both are set", async () => {
|
||||
mockedCreateHaiAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
||||
);
|
||||
|
||||
await reviewStep(
|
||||
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||
undefined,
|
||||
{
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
const opts = mockedCreateHaiAgent.mock.calls[0][0];
|
||||
expect(opts.defaultProvider).toBe("anthropic");
|
||||
expect(opts.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("falls back to defaultProvider/defaultModelId when validatorModelProvider is missing", async () => {
|
||||
mockedCreateHaiAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
||||
);
|
||||
|
||||
await reviewStep(
|
||||
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||
undefined,
|
||||
{
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
// validatorModelProvider is missing
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
const opts = mockedCreateHaiAgent.mock.calls[0][0];
|
||||
expect(opts.defaultProvider).toBe("openai");
|
||||
expect(opts.defaultModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("falls back to defaultProvider/defaultModelId when validatorModelId is missing", async () => {
|
||||
mockedCreateHaiAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
||||
);
|
||||
|
||||
await reviewStep(
|
||||
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||
undefined,
|
||||
{
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
// validatorModelId is missing
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
const opts = mockedCreateHaiAgent.mock.calls[0][0];
|
||||
expect(opts.defaultProvider).toBe("openai");
|
||||
expect(opts.defaultModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("falls back to defaultProvider/defaultModelId when both validator fields are undefined", async () => {
|
||||
mockedCreateHaiAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
||||
);
|
||||
|
||||
await reviewStep(
|
||||
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||
undefined,
|
||||
{
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
const opts = mockedCreateHaiAgent.mock.calls[0][0];
|
||||
expect(opts.defaultProvider).toBe("openai");
|
||||
expect(opts.defaultModelId).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,6 +144,10 @@ export interface ReviewOptions {
|
||||
defaultProvider?: string;
|
||||
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
|
||||
defaultModelId?: string;
|
||||
/** Validator model provider override. When both `validatorModelProvider` and `validatorModelId` are set, they take precedence over `defaultProvider`/`defaultModelId`. */
|
||||
validatorModelProvider?: string;
|
||||
/** Validator model ID override. When both `validatorModelProvider` and `validatorModelId` are set, they take precedence over `defaultProvider`/`defaultModelId`. */
|
||||
validatorModelId?: string;
|
||||
/** Default thinking effort level for the reviewer agent session. */
|
||||
defaultThinkingLevel?: string;
|
||||
/** Task store for persisting agent log entries. When provided with `taskId`, enables full conversation logging. */
|
||||
@@ -182,6 +186,15 @@ export async function reviewStep(
|
||||
})
|
||||
: null;
|
||||
|
||||
// Resolve validator model settings: use per-task overrides if both provider and modelId are set,
|
||||
// otherwise fall back to defaultProvider/defaultModelId
|
||||
const validatorProvider = options.validatorModelProvider && options.validatorModelId
|
||||
? options.validatorModelProvider
|
||||
: options.defaultProvider;
|
||||
const validatorModelId = options.validatorModelProvider && options.validatorModelId
|
||||
? options.validatorModelId
|
||||
: options.defaultModelId;
|
||||
|
||||
// Spawn a reviewer agent with read-only tools
|
||||
const { session } = await createKbAgent({
|
||||
cwd,
|
||||
@@ -191,8 +204,8 @@ export async function reviewStep(
|
||||
onThinking: agentLogger?.onThinking,
|
||||
onToolStart: agentLogger?.onToolStart,
|
||||
onToolEnd: agentLogger?.onToolEnd,
|
||||
defaultProvider: options.defaultProvider,
|
||||
defaultModelId: options.defaultModelId,
|
||||
defaultProvider: validatorProvider,
|
||||
defaultModelId: validatorModelId,
|
||||
defaultThinkingLevel: options.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user