From 6d79a13e2328f634d7b1858216a3ef56da3572ff Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 2 Mar 2026 11:50:37 +0000 Subject: [PATCH 1/3] fix: move model resolution from TUI handlers to runtime coordinator TUI handlers always filled in a default model before runtime started, causing all experts to run with claude-sonnet-4-5 regardless of their defaultModelTier. Now model is optional at the TUI boundary and the coordinator-executor resolves it after expert setup, respecting each expert's defaultModelTier from both local config and API-resolved experts. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/schemas/runtime.ts | 10 +- .../coordinator-executor.test.ts | 128 ++++++++++++++++-- .../src/orchestration/coordinator-executor.ts | 31 ++++- .../orchestration/delegation-executor.test.ts | 45 ++++++ .../src/hooks/state/use-runtime-info.ts | 2 +- packages/tui-components/src/types/base.ts | 2 +- packages/tui/src/lib/context.test.ts | 6 +- packages/tui/src/lib/context.ts | 5 +- packages/tui/src/run-handler.ts | 15 +- packages/tui/src/start-handler.ts | 17 +-- 10 files changed, 199 insertions(+), 62 deletions(-) diff --git a/packages/core/src/schemas/runtime.ts b/packages/core/src/schemas/runtime.ts index 4d5a5eb9..4009d391 100644 --- a/packages/core/src/schemas/runtime.ts +++ b/packages/core/src/schemas/runtime.ts @@ -60,8 +60,8 @@ export interface RunInput { /** Runtime settings for an Expert run */ export interface RunSetting { - /** Model name to use */ - model: string + /** Model name to use (resolved at runtime if not provided) */ + model?: string /** Provider configuration */ providerConfig: ProviderConfig /** Job ID this run belongs to */ @@ -120,7 +120,7 @@ type ExpertInput = { /** Input type for runParamsSchema (before defaults/transforms) */ export type RunParamsInput = { setting: { - model: string + model?: string providerConfig: ProviderConfig jobId?: string runId?: string @@ -143,7 +143,7 @@ export type RunParamsInput = { } export const runSettingSchema = z.object({ - model: z.string(), + model: z.string().optional(), providerConfig: providerConfigSchema, jobId: z.string(), runId: z.string(), @@ -175,7 +175,7 @@ export const runSettingSchema = z.object({ export const runParamsSchema = z.object({ setting: z.object({ - model: z.string(), + model: z.string().optional(), providerConfig: providerConfigSchema, jobId: z .string() diff --git a/packages/runtime/src/orchestration/coordinator-executor.test.ts b/packages/runtime/src/orchestration/coordinator-executor.test.ts index d61ab1e3..18a29ca6 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.test.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.test.ts @@ -18,25 +18,29 @@ const capturedOnLifecycleEvent: { current: undefined, } -// Mock dependencies -mock.module("../helpers/index.js", () => ({ - getContextWindow: mock().mockReturnValue(100000), - setupExperts: mock().mockResolvedValue({ - expertToRun: { +// Configurable setupExperts result for model resolution tests +const defaultSetupExpertsResult = { + expertToRun: { + key: "test-expert", + name: "Test Expert", + version: "1.0.0", + instructions: "Test instructions", + }, + experts: { + "test-expert": { key: "test-expert", name: "Test Expert", version: "1.0.0", instructions: "Test instructions", }, - experts: { - "test-expert": { - key: "test-expert", - name: "Test Expert", - version: "1.0.0", - instructions: "Test instructions", - }, - }, - }), + }, +} +const mockSetupExperts = mock().mockResolvedValue(defaultSetupExpertsResult) + +// Mock dependencies +mock.module("../helpers/index.js", () => ({ + getContextWindow: mock().mockReturnValue(100000), + setupExperts: mockSetupExperts, validateRuntimeVersion: mock(), createInitialCheckpoint: mock().mockImplementation( (id: string, params: Record) => ({ @@ -92,6 +96,12 @@ mock.module("@perstack/skill-manager", () => ({ }, })) +mock.module("../helpers/provider-adapter-factory.js", () => ({ + createProviderAdapter: mock().mockResolvedValue({ + createModel: mock().mockReturnValue({}), + }), +})) + mock.module("../state-machine/index.js", () => ({ executeStateMachine: mock().mockResolvedValue({ id: "result-cp", @@ -317,6 +327,96 @@ describe("@perstack/runtime: coordinator-executor", () => { }), ) }) + + it("uses setting.model when explicitly provided", async () => { + const { executeStateMachine } = await import("../state-machine/index.js") + const executor = new CoordinatorExecutor() + const setting = createMockSetting({ model: "my-custom-model" }) + + await executor.execute(setting) + + expect(executeStateMachine).toHaveBeenCalledWith( + expect.objectContaining({ + setting: expect.objectContaining({ model: "my-custom-model" }), + }), + ) + }) + + it("resolves model from expert defaultModelTier when setting.model is undefined", async () => { + const { executeStateMachine } = await import("../state-machine/index.js") + mockSetupExperts.mockResolvedValueOnce({ + expertToRun: { + key: "test-expert", + name: "Test Expert", + version: "1.0.0", + instructions: "Test instructions", + defaultModelTier: "low", + }, + experts: { + "test-expert": { + key: "test-expert", + name: "Test Expert", + version: "1.0.0", + instructions: "Test instructions", + defaultModelTier: "low", + }, + }, + }) + const executor = new CoordinatorExecutor() + const setting = createMockSetting({ model: undefined }) + + await executor.execute(setting) + + // "low" tier for anthropic resolves to claude-haiku-4-5 + expect(executeStateMachine).toHaveBeenCalledWith( + expect.objectContaining({ + setting: expect.objectContaining({ model: "claude-haiku-4-5" }), + }), + ) + }) + + it("falls back to middle tier when neither model nor expert tier exists", async () => { + const { executeStateMachine } = await import("../state-machine/index.js") + mockSetupExperts.mockResolvedValueOnce({ + expertToRun: { + key: "test-expert", + name: "Test Expert", + version: "1.0.0", + instructions: "Test instructions", + }, + experts: { + "test-expert": { + key: "test-expert", + name: "Test Expert", + version: "1.0.0", + instructions: "Test instructions", + }, + }, + }) + const executor = new CoordinatorExecutor() + const setting = createMockSetting({ model: undefined }) + + await executor.execute(setting) + + // "middle" tier for anthropic resolves to claude-sonnet-4-5 + expect(executeStateMachine).toHaveBeenCalledWith( + expect.objectContaining({ + setting: expect.objectContaining({ model: "claude-sonnet-4-5" }), + }), + ) + }) + + it("throws PerstackError when model cannot be resolved for unknown provider", async () => { + const executor = new CoordinatorExecutor() + const setting = createMockSetting({ + model: undefined, + providerConfig: { providerName: "unknown-provider" } as RunSetting["providerConfig"], + }) + + await expect(executor.execute(setting)).rejects.toThrow( + 'Cannot resolve model for provider "unknown-provider"', + ) + }) }) describe("lifecycle event bridge", () => { diff --git a/packages/runtime/src/orchestration/coordinator-executor.ts b/packages/runtime/src/orchestration/coordinator-executor.ts index 104ec53e..3e99ae9b 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.ts @@ -4,9 +4,11 @@ import { createRuntimeEvent, type Expert, type Lockfile, + PerstackError, type RunEvent, type RunSetting, type RuntimeEvent, + resolveModelTier, type Step, } from "@perstack/core" import { type SkillAdapterLifecycleEvent, SkillManager } from "@perstack/skill-manager" @@ -55,14 +57,30 @@ export class CoordinatorExecutor { const adapter = await createProviderAdapter(setting.providerConfig, { proxyUrl: setting.proxyUrl, }) - const model = adapter.createModel(setting.model) - const llmExecutor = new LLMExecutor(adapter, model) - const contextWindow = getContextWindow(setting.providerConfig.providerName, setting.model) const { expertToRun, experts } = await setupExperts(setting, this.options.resolveExpertToRun) validateRuntimeVersion(experts) - this.emitInitEvent(setting, expertToRun, experts) + // Resolve model: explicit setting > expert's default tier > middle tier fallback + const resolvedModel = + setting.model ?? + (expertToRun.defaultModelTier + ? resolveModelTier(setting.providerConfig.providerName, expertToRun.defaultModelTier) + : undefined) ?? + resolveModelTier(setting.providerConfig.providerName, "middle") + + if (!resolvedModel) { + throw new PerstackError( + `Cannot resolve model for provider "${setting.providerConfig.providerName}". Specify a model explicitly with --model.`, + ) + } + + const model = adapter.createModel(resolvedModel) + const llmExecutor = new LLMExecutor(adapter, model) + + const contextWindow = getContextWindow(setting.providerConfig.providerName, resolvedModel) + + this.emitInitEvent(setting, resolvedModel, expertToRun, experts) const onLifecycleEvent = this.createLifecycleEventBridge(setting) @@ -97,7 +115,7 @@ export class CoordinatorExecutor { eventEmitter.subscribe(eventListener) const resultCheckpoint = await executeStateMachine({ - setting: { ...setting, experts }, + setting: { ...setting, model: resolvedModel, experts }, initialCheckpoint, eventListener, skillManager, @@ -177,6 +195,7 @@ export class CoordinatorExecutor { private emitInitEvent( setting: RunSetting, + resolvedModel: string, expertToRun: Expert, experts: Record, ): void { @@ -186,7 +205,7 @@ export class CoordinatorExecutor { runtimeVersion: pkg.version, expertName: expertToRun.name, experts: Object.keys(experts), - model: setting.model, + model: resolvedModel, maxRetries: setting.maxRetries, timeout: setting.timeout, query: setting.input.text, diff --git a/packages/runtime/src/orchestration/delegation-executor.test.ts b/packages/runtime/src/orchestration/delegation-executor.test.ts index 9a82a2e0..16fb6666 100644 --- a/packages/runtime/src/orchestration/delegation-executor.test.ts +++ b/packages/runtime/src/orchestration/delegation-executor.test.ts @@ -427,6 +427,51 @@ describe("@perstack/runtime: delegation-executor", () => { expect(delegateSetting.model).toBe("claude-haiku-4-5") }) + it("resolves delegate model from tier when parent model is undefined", async () => { + const executor = new DelegationExecutor() + const setting = createMockSetting({ + model: undefined, + providerConfig: { providerName: "anthropic" } as RunSetting["providerConfig"], + experts: { + "expert-a": { + key: "expert-a", + name: "expert-a", + version: "1.0.0", + instruction: "test", + skills: {}, + delegates: [], + tags: [], + minRuntimeVersion: "v1.0", + defaultModelTier: "high", + }, + } as RunSetting["experts"], + }) + const delegation = createMockDelegation({ + toolCallId: "tc-1", + expert: { key: "expert-a", name: "A", version: "1" }, + }) + const context = createMockContext() + const parentExpert = { key: "parent", name: "Parent", version: "1.0" } + + const resultCheckpoint: Checkpoint = { + ...createMockCheckpoint(), + messages: [ + { + id: "msg-expert-a", + type: "expertMessage", + contents: [{ type: "textPart", id: "txt-1", text: "Result" }], + }, + ], + } + const runFn = mock().mockResolvedValueOnce(resultCheckpoint) + + await executor.execute([delegation], setting, context, parentExpert, runFn) + + const delegateSetting = runFn.mock.calls[0][0].setting + // "high" tier for anthropic resolves to claude-opus-4-6 (first match) + expect(delegateSetting.model).toBe("claude-opus-4-6") + }) + it("inherits parent model when delegate has no defaultModelTier", async () => { const executor = new DelegationExecutor() const setting = createMockSetting({ diff --git a/packages/tui-components/src/hooks/state/use-runtime-info.ts b/packages/tui-components/src/hooks/state/use-runtime-info.ts index 9c7c411c..f9e10300 100644 --- a/packages/tui-components/src/hooks/state/use-runtime-info.ts +++ b/packages/tui-components/src/hooks/state/use-runtime-info.ts @@ -12,7 +12,7 @@ export const useRuntimeInfo = (options: UseRuntimeInfoOptions) => { status: "initializing", runtimeVersion: options.initialConfig.runtimeVersion, expertName: options.initialExpertName, - model: options.initialConfig.model, + model: options.initialConfig.model ?? "", maxRetries: options.initialConfig.maxRetries, timeout: options.initialConfig.timeout, activeSkills: [], diff --git a/packages/tui-components/src/types/base.ts b/packages/tui-components/src/types/base.ts index a73e275b..9d247163 100644 --- a/packages/tui-components/src/types/base.ts +++ b/packages/tui-components/src/types/base.ts @@ -15,7 +15,7 @@ export type RuntimeInfo = { } export type InitialRuntimeConfig = { runtimeVersion: string - model: string + model: string | undefined maxRetries: number timeout: number contextWindowUsage: number diff --git a/packages/tui/src/lib/context.test.ts b/packages/tui/src/lib/context.test.ts index 311fc132..6a85ed78 100644 --- a/packages/tui/src/lib/context.test.ts +++ b/packages/tui/src/lib/context.test.ts @@ -47,15 +47,15 @@ describe("resolveRunContext", () => { expect(context.perstackConfig).toBeDefined() expect(context.env).toBeDefined() expect(context.providerConfig).toBeDefined() - expect(context.model).toBeDefined() + expect(context.model).toBeUndefined() expect(context.experts).toBeDefined() }) - it("uses default provider and model", async () => { + it("uses default provider and model is undefined when not configured", async () => { const context = await resolveRunContext({ perstackConfig: minimalConfig }) expect(context.providerConfig.providerName).toBe("anthropic") - expect(context.model).toBe("claude-sonnet-4-5") + expect(context.model).toBeUndefined() }) it("uses provider from input", async () => { diff --git a/packages/tui/src/lib/context.ts b/packages/tui/src/lib/context.ts index b3187eb1..69f00a8c 100644 --- a/packages/tui/src/lib/context.ts +++ b/packages/tui/src/lib/context.ts @@ -10,7 +10,6 @@ import { getProviderConfig } from "./provider-config.js" import { getCheckpointById, getMostRecentCheckpoint } from "./run-manager.js" const defaultProvider: ProviderName = "anthropic" -const defaultModel = "claude-sonnet-4-5" export type ExpertConfig = NonNullable[string] @@ -19,7 +18,7 @@ export type RunContext = { checkpoint: Checkpoint | undefined env: Record providerConfig: ProviderConfig - model: string + model: string | undefined experts: Record } @@ -65,7 +64,7 @@ export async function resolveRunContext(input: ResolveRunContextInput): Promise< const provider = (input.provider ?? perstackConfig.provider?.providerName ?? defaultProvider) as ProviderName - const model = input.model ?? perstackConfig.model ?? defaultModel + const model = input.model ?? perstackConfig.model const providerConfig = getProviderConfig(provider, env, perstackConfig.provider) const experts = Object.fromEntries( diff --git a/packages/tui/src/run-handler.ts b/packages/tui/src/run-handler.ts index 09a89fb0..3fda5766 100644 --- a/packages/tui/src/run-handler.ts +++ b/packages/tui/src/run-handler.ts @@ -3,7 +3,6 @@ import type { Lockfile, PerstackConfig, RunEvent, RuntimeEvent } from "@perstack import { createFilteredEventListener, parseWithFriendlyError, - resolveModelTier, runCommandInputSchema, validateEventFilter, } from "@perstack/core" @@ -58,18 +57,6 @@ export async function runHandler( expertKey: input.expertKey, }) - // Resolve per-expert model tier for the entry expert (CLI --model takes priority) - let resolvedModel = model - if (!input.options.model) { - const expertConfig = perstackConfig.experts?.[input.expertKey] - if (expertConfig?.defaultModelTier) { - const tierModel = resolveModelTier(providerConfig.providerName, expertConfig.defaultModelTier) - if (tierModel) { - resolvedModel = tierModel - } - } - } - if (handlerOptions?.additionalEnv) { Object.assign(env, handlerOptions.additionalEnv(env)) } @@ -93,7 +80,7 @@ export async function runHandler( : { text: input.query })) : { text: input.query }, experts, - model: resolvedModel, + model: model, providerConfig, reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index 19b8a3f4..1182236b 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -5,7 +5,6 @@ import { type Lockfile, type PerstackConfig, parseWithFriendlyError, - resolveModelTier, startCommandInputSchema, } from "@perstack/core" import { @@ -128,18 +127,6 @@ export async function startHandler( return } - // Resolve per-expert model tier for the selected expert (CLI --model takes priority) - let resolvedModel = model - if (!input.options.model) { - const expertConfig = perstackConfig.experts?.[selection.expertKey] - if (expertConfig?.defaultModelTier) { - const tierModel = resolveModelTier(providerConfig.providerName, expertConfig.defaultModelTier) - if (tierModel) { - resolvedModel = tierModel - } - } - } - const lockfile = handlerOptions.lockfile // Phase 3: Execution loop @@ -176,7 +163,7 @@ export async function startHandler( query: currentQuery ?? undefined, config: { runtimeVersion, - model: resolvedModel, + model: model, maxRetries, timeout, contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, @@ -199,7 +186,7 @@ export async function startHandler( ? parseInteractiveToolCallResult(resolvedQuery, currentCheckpoint) : { text: resolvedQuery }, experts, - model: resolvedModel, + model: model, providerConfig, reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, From 71527f6deca86d661d4149b79d327d567dfcbfd3 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 2 Mar 2026 12:14:08 +0000 Subject: [PATCH 2/3] chore: add changeset for model resolution fix Co-Authored-By: Claude Opus 4.6 --- .changeset/move-model-resolution-to-runtime.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/move-model-resolution-to-runtime.md diff --git a/.changeset/move-model-resolution-to-runtime.md b/.changeset/move-model-resolution-to-runtime.md new file mode 100644 index 00000000..29b80456 --- /dev/null +++ b/.changeset/move-model-resolution-to-runtime.md @@ -0,0 +1,9 @@ +--- +"@perstack/core": patch +"@perstack/runtime": patch +"@perstack/tui": patch +"@perstack/tui-components": patch +"perstack": patch +--- + +Move model resolution from TUI handlers to runtime coordinator so each expert's defaultModelTier is respected From 777993ec792689e2285717e398f602b52e3c604b Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 2 Mar 2026 12:17:15 +0000 Subject: [PATCH 3/3] chore: use shorthand property for model Co-Authored-By: Claude Opus 4.6 --- packages/tui/src/run-handler.ts | 2 +- packages/tui/src/start-handler.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/run-handler.ts b/packages/tui/src/run-handler.ts index 3fda5766..58c2dc3d 100644 --- a/packages/tui/src/run-handler.ts +++ b/packages/tui/src/run-handler.ts @@ -80,7 +80,7 @@ export async function runHandler( : { text: input.query })) : { text: input.query }, experts, - model: model, + model, providerConfig, reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index 1182236b..860d0b49 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -163,7 +163,7 @@ export async function startHandler( query: currentQuery ?? undefined, config: { runtimeVersion, - model: model, + model, maxRetries, timeout, contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, @@ -186,7 +186,7 @@ export async function startHandler( ? parseInteractiveToolCallResult(resolvedQuery, currentCheckpoint) : { text: resolvedQuery }, experts, - model: model, + model, providerConfig, reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries,