Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/move-model-resolution-to-runtime.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 5 additions & 5 deletions packages/core/src/schemas/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down
128 changes: 114 additions & 14 deletions packages/runtime/src/orchestration/coordinator-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down
31 changes: 25 additions & 6 deletions packages/runtime/src/orchestration/coordinator-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -177,6 +195,7 @@ export class CoordinatorExecutor {

private emitInitEvent(
setting: RunSetting,
resolvedModel: string,
expertToRun: Expert,
experts: Record<string, Expert>,
): void {
Expand All @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions packages/runtime/src/orchestration/delegation-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
2 changes: 1 addition & 1 deletion packages/tui-components/src/types/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type RuntimeInfo = {
}
export type InitialRuntimeConfig = {
runtimeVersion: string
model: string
model: string | undefined
maxRetries: number
timeout: number
contextWindowUsage: number
Expand Down
6 changes: 3 additions & 3 deletions packages/tui/src/lib/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/tui/src/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PerstackConfig["experts"]>[string]

Expand All @@ -19,7 +18,7 @@ export type RunContext = {
checkpoint: Checkpoint | undefined
env: Record<string, string>
providerConfig: ProviderConfig
model: string
model: string | undefined
experts: Record<string, ExpertConfig & { name: string; version: string }>
}

Expand Down Expand Up @@ -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(
Expand Down
Loading