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:
gsxdsm
2026-03-29 19:36:15 -07:00
parent 2c865d9914
commit a5393463d5
16 changed files with 1157 additions and 13 deletions

View File

@@ -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");
});
});

View File

@@ -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,
},

View File

@@ -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");
});
});

View File

@@ -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,
});