freeze model + provider per run at creation time

The model dropdown was broken in two ways: it wrote to ~/.rowboat/config/models.json
(the BYOK creds file, stamped with a fake `flavor: 'openrouter'` to satisfy zod
when signed in), and the runtime ignored that write entirely for signed-in users
because `streamAgent` hard-coded `gpt-5.4`. Model selection was also globally
scoped, so every chat shared one brain.

This change moves model + provider out of the global config and onto the run
itself, resolved once at runs:create and frozen for the run's lifetime.

## Resolution

`runsCore.createRun` resolves per-field, falling through:

  run.model    = opts.model    ?? agent.model    ?? defaults.model
  run.provider = opts.provider ?? agent.provider ?? defaults.provider

A new `core/models/defaults.ts` is the only place in the codebase that branches
on signed-in state. `getDefaultModelAndProvider()` returns name strings;
`resolveProviderConfig(name)` does the name → full LlmProvider lookup at
runtime. `createProvider` learns about `flavor: 'rowboat'` so the gateway is
just another flavor.

`provider` is stored as a name (e.g. `"rowboat"`, `"openai"`), not a full
LlmProvider object. API keys never get written into the JSONL log; rotating a
key in models.json applies to existing runs without re-creation. Cost: deleting
a provider from settings breaks runs that referenced it (clear error surfaced
via `resolveProviderConfig`).

## Runtime

`streamAgent` no longer resolves anything — it reads `state.runModel` /
`state.runProvider`, looks up the provider config, instantiates. Subflows
inherit the parent run's pair, so KG / inline-task subagents run on whatever
the main run resolved to at creation. The `knowledgeGraphAgents` array,
`isKgAgent`, and the per-agent default constants are gone.

KG / inline-task / pre-built agents declare their preferred model in YAML
frontmatter (claude-haiku-4.5 / claude-sonnet-4.6) — used at resolution time
when those agents are themselves the top-level agent of a run (background
triggers, scheduled tasks, etc.).

## Standalone callers

Non-run LLM call sites (summarize_meeting, track/routing, builtin-tools
parseFile) and `agent-schedule/runner` were branching on signed-in
independently. They all route through `getDefaultModelAndProvider` +
`resolveProviderConfig` + `createProvider` now; `agent-schedule/runner`
switched from raw `runsRepo.create` to `runsCore.createRun` so resolution
applies to scheduled-agent runs too.

## UI

`chat-input-with-mentions` stops calling `models:saveConfig`. The dropdown
notifies the parent via `onSelectedModelChange` ({provider, model} as names);
App.tsx stashes selection per-tab and passes it to the next `runs:create`.
When a run already exists, the input fetches it and renders a static label —
model can't change mid-run.

## Legacy runs

A lenient zod schema in `repo.ts` (`StartEvent.extend(...optional)` plus
`RunEvent.or(LegacyStartEvent)`) parses pre-existing runs. `repo.fetch` fills
missing model/provider from current defaults and returns the strict canonical
`Run` type. No file-rewriting migration; no impact on the canonical schema in
`@x/shared`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-04-22 12:26:01 +05:30
parent 51f2ad6e8a
commit 5c4aa77255
22 changed files with 256 additions and 179 deletions

View file

@ -8,6 +8,7 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
import { MessageEvent } from "@x/shared/dist/runs.js";
import { createRun } from "../runs/runs.js";
import z from "zod";
const DEFAULT_STARTING_MESSAGE = "go";
@ -162,8 +163,8 @@ async function runAgent(
});
try {
// Create a new run
const run = await runsRepo.create({ agentId: agentName });
// Create a new run via core (resolves agent + default model+provider).
const run = await createRun({ agentId: agentName });
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
// Add the starting message as a user message

View file

@ -16,8 +16,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { isSignedIn } from "../account/account.js";
import { getGatewayProvider } from "../models/gateway.js";
import { resolveProviderConfig } from "../models/defaults.js";
import { IAgentsRepo } from "./repo.js";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { IBus } from "../application/lib/bus.js";
@ -649,6 +648,8 @@ export class AgentState {
runId: string | null = null;
agent: z.infer<typeof Agent> | null = null;
agentName: string | null = null;
runModel: string | null = null;
runProvider: string | null = null;
messages: z.infer<typeof MessageList> = [];
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
subflowStates: Record<string, AgentState> = {};
@ -762,13 +763,18 @@ export class AgentState {
case "start":
this.runId = event.runId;
this.agentName = event.agentName;
this.runModel = event.model;
this.runProvider = event.provider;
break;
case "spawn-subflow":
// Seed the subflow state with its agent so downstream loadAgent works.
// Subflows inherit the parent run's model+provider — there's one pair per run.
if (!this.subflowStates[event.toolCallId]) {
this.subflowStates[event.toolCallId] = new AgentState();
}
this.subflowStates[event.toolCallId].agentName = event.agentName;
this.subflowStates[event.toolCallId].runModel = this.runModel;
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
break;
case "message":
this.messages.push(event.message);
@ -857,35 +863,23 @@ export async function* streamAgent({
yield event;
}
const modelConfig = await modelConfigRepo.getConfig();
if (!modelConfig) {
throw new Error("Model config not found");
}
// set up agent
const agent = await loadAgent(state.agentName!);
// set up tools
const tools = await buildTools(agent);
// set up provider + model
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(modelConfig.provider);
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
const isInlineTaskAgent = state.agentName === "inline_task_agent";
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel;
const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel;
const modelId = isInlineTaskAgent
? defaultInlineTaskModel
: (isKgAgent && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: isKgAgent ? defaultKgModel : defaultModel;
// model+provider were resolved and frozen on the run at runs:create time.
// Look up the named provider's current credentials from models.json and
// instantiate the LLM client. No selection happens here.
if (!state.runModel || !state.runProvider) {
throw new Error(`Run ${runId} is missing model/provider on its start event`);
}
const modelId = state.runModel;
const providerConfig = await resolveProviderConfig(state.runProvider);
const provider = createProvider(providerConfig);
const model = provider.languageModel(modelId);
logger.log(`using model: ${modelId}`);
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
let loopCounter = 0;
let voiceInput = false;

View file

@ -21,9 +21,8 @@ import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/d
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
import { createProvider } from "../../models/models.js";
import { IModelConfigRepo } from "../../models/repo.js";
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js";
import { isSignedIn } from "../../account/account.js";
import { getGatewayProvider } from "../../models/gateway.js";
import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js";
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
@ -746,13 +745,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const base64 = buffer.toString('base64');
// Resolve model config from DI container
const modelConfigRepo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const modelConfig = await modelConfigRepo.getConfig();
const provider = await isSignedIn()
? await getGatewayProvider()
: createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model);
const { model: modelId, provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const userPrompt = prompt || 'Convert this file to well-structured markdown.';

View file

@ -1,5 +1,6 @@
export function getRaw(): string {
return `---
model: anthropic/claude-haiku-4.5
tools:
workspace-writeFile:
type: builtin

View file

@ -13,7 +13,7 @@ export function getRaw(): string {
const defaultEndISO = defaultEnd.toISOString();
return `---
model: gpt-5.2
model: anthropic/claude-sonnet-4.6
tools:
${toolEntries}
---

View file

@ -2,7 +2,7 @@ import { renderTagSystemForEmails } from './tag_system.js';
export function getRaw(): string {
return `---
model: gpt-5.2
model: anthropic/claude-haiku-4.5
tools:
workspace-readFile:
type: builtin

View file

@ -3,7 +3,7 @@ import { renderNoteEffectRules } from './tag_system.js';
export function getRaw(): string {
return `---
model: gpt-5.2
model: anthropic/claude-haiku-4.5
tools:
workspace-writeFile:
type: builtin

View file

@ -2,7 +2,7 @@ import { renderTagSystemForNotes } from './tag_system.js';
export function getRaw(): string {
return `---
model: gpt-5.2
model: anthropic/claude-haiku-4.5
tools:
workspace-readFile:
type: builtin

View file

@ -1,11 +1,8 @@
import fs from 'fs';
import path from 'path';
import { generateText } from 'ai';
import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js';
import { createProvider } from '../models/models.js';
import { isSignedIn } from '../account/account.js';
import { getGatewayProvider } from '../models/gateway.js';
import { getDefaultModelAndProvider, resolveProviderConfig } from '../models/defaults.js';
import { WorkDir } from '../config/config.js';
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
@ -138,15 +135,9 @@ function loadCalendarEventContext(calendarEventJson: string): string {
}
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
const modelId = config.meetingNotesModel
|| (signedIn ? "gpt-5.4" : config.model);
const model = provider.languageModel(modelId);
const { model: modelId, provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
// If a specific calendar event was linked, use it directly.
// Otherwise fall back to scanning events within ±3 hours.

View file

@ -1,11 +1,8 @@
import { generateObject } from 'ai';
import { trackBlock, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import container from '../../di/container.js';
import type { IModelConfigRepo } from '../../models/repo.js';
import { createProvider } from '../../models/models.js';
import { isSignedIn } from '../../account/account.js';
import { getGatewayProvider } from '../../models/gateway.js';
import { getDefaultModelAndProvider, resolveProviderConfig } from '../../models/defaults.js';
const log = new PrefixLogger('TrackRouting');
@ -37,15 +34,9 @@ Rules:
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
async function resolveModel() {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
const modelId = config.knowledgeGraphModel
|| (signedIn ? 'gpt-5.4' : config.model);
return provider.languageModel(modelId);
const { model, provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
return createProvider(config).languageModel(model);
}
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {

View file

@ -0,0 +1,53 @@
import z from "zod";
import { LlmProvider } from "@x/shared/dist/models.js";
import { IModelConfigRepo } from "./repo.js";
import { isSignedIn } from "../account/account.js";
import container from "../di/container.js";
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
/**
* The single source of truth for "what model+provider should we use when
* the caller didn't specify and the agent didn't declare". Returns names only.
* This is the only place that branches on signed-in state.
*/
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
if (await isSignedIn()) {
return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
}
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const cfg = await repo.getConfig();
return { model: cfg.model, provider: cfg.provider.flavor };
}
/**
* Resolve a provider name (as stored on a run, an agent, or returned by
* getDefaultModelAndProvider) into the full LlmProvider config that
* createProvider expects (apiKey/baseURL/headers).
*
* - "rowboat" gateway provider (auth via OAuth bearer; no creds field).
* - other names look up models.json's `providers[name]` map.
* - fallback: if the name matches the active default's flavor (legacy
* single-provider configs that didn't write to the providers map yet).
*/
export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> {
if (name === "rowboat") {
return { flavor: "rowboat" };
}
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const cfg = await repo.getConfig();
const entry = cfg.providers?.[name];
if (entry) {
return LlmProvider.parse({
flavor: name,
apiKey: entry.apiKey,
baseURL: entry.baseURL,
headers: entry.headers,
});
}
if (cfg.provider.flavor === name) {
return cfg.provider;
}
throw new Error(`Provider '${name}' is referenced but not configured`);
}

View file

@ -10,7 +10,7 @@ const authedFetch: typeof fetch = async (input, init) => {
return fetch(input, { ...init, headers });
};
export async function getGatewayProvider(): Promise<ProviderV2> {
export function getGatewayProvider(): ProviderV2 {
return createOpenRouter({
baseURL: `${API_URL}/v1/llm`,
apiKey: 'managed-by-rowboat',

View file

@ -8,7 +8,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
import { isSignedIn } from "../account/account.js";
import { getGatewayProvider } from "./gateway.js";
export const Provider = LlmProvider;
@ -65,6 +64,8 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
baseURL,
headers,
}) as unknown as ProviderV2;
case "rowboat":
return getGatewayProvider();
default:
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
}
@ -80,9 +81,7 @@ export async function testModelConnection(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), effectiveTimeout);
try {
const provider = await isSignedIn()
? await getGatewayProvider()
: createProvider(providerConfig);
const provider = createProvider(providerConfig);
const languageModel = provider.languageModel(model);
await generateText({
model: languageModel,

View file

@ -1,5 +1,5 @@
---
model: gpt-4.1
model: anthropic/claude-haiku-4.5
tools:
workspace-readFile:
type: builtin

View file

@ -1,5 +1,5 @@
---
model: gpt-4.1
model: anthropic/claude-haiku-4.5
tools:
workspace-readFile:
type: builtin

View file

@ -6,9 +6,28 @@ import fsp from "fs/promises";
import fs from "fs";
import readline from "readline";
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js";
import { getDefaultModelAndProvider } from "../models/defaults.js";
/**
* Reading-only schemas: extend the canonical `StartEvent` / `RunEvent` to
* accept legacy run files written before `model`/`provider` were required.
*
* `RunEvent.or(LegacyStartEvent)` works because zod unions try left-to-right:
* for any non-start event RunEvent matches first; for a strict start event
* RunEvent still matches; only a legacy start event falls through and parses
* as LegacyStartEvent. New event types stay maintained in one place
* (`@x/shared/dist/runs.js`) the lenient form just adds one fallback variant.
*/
const LegacyStartEvent = StartEvent.extend({
model: z.string().optional(),
provider: z.string().optional(),
});
const ReadRunEvent = RunEvent.or(LegacyStartEvent);
export type CreateRunRepoOptions = Required<z.infer<typeof CreateRunOptions>>;
export interface IRunsRepo {
create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>;
create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>;
fetch(id: string): Promise<z.infer<typeof Run>>;
list(cursor?: string): Promise<z.infer<typeof ListRunsResponse>>;
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
@ -69,16 +88,19 @@ export class FSRunsRepo implements IRunsRepo {
/**
* Read file line-by-line using streams, stopping early once we have
* the start event and title (or determine there's no title).
*
* Parses the start event with `LegacyStartEvent` so runs written before
* `model`/`provider` were required still surface in the list view.
*/
private async readRunMetadata(filePath: string): Promise<{
start: z.infer<typeof StartEvent>;
start: z.infer<typeof LegacyStartEvent>;
title: string | undefined;
} | null> {
return new Promise((resolve) => {
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let start: z.infer<typeof StartEvent> | null = null;
let start: z.infer<typeof LegacyStartEvent> | null = null;
let title: string | undefined;
let lineIndex = 0;
@ -88,11 +110,10 @@ export class FSRunsRepo implements IRunsRepo {
try {
if (lineIndex === 0) {
// First line should be the start event
start = StartEvent.parse(JSON.parse(trimmed));
start = LegacyStartEvent.parse(JSON.parse(trimmed));
} else {
// Subsequent lines - look for first user message or assistant response
const event = RunEvent.parse(JSON.parse(trimmed));
const event = ReadRunEvent.parse(JSON.parse(trimmed));
if (event.type === 'message') {
const msg = event.message;
if (msg.role === 'user') {
@ -157,13 +178,15 @@ export class FSRunsRepo implements IRunsRepo {
);
}
async create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
async create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>> {
const runId = await this.idGenerator.next();
const ts = new Date().toISOString();
const start: z.infer<typeof StartEvent> = {
type: "start",
runId,
agentName: options.agentId,
model: options.model,
provider: options.provider,
subflow: [],
ts,
};
@ -172,24 +195,41 @@ export class FSRunsRepo implements IRunsRepo {
id: runId,
createdAt: ts,
agentId: options.agentId,
model: options.model,
provider: options.provider,
log: [start],
};
}
async fetch(id: string): Promise<z.infer<typeof Run>> {
const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8');
const events = contents.split('\n')
// Parse with the lenient schema so legacy start events (no model/provider) load.
const rawEvents = contents.split('\n')
.filter(line => line.trim() !== '')
.map(line => RunEvent.parse(JSON.parse(line)));
if (events.length === 0 || events[0].type !== 'start') {
.map(line => ReadRunEvent.parse(JSON.parse(line)));
if (rawEvents.length === 0 || rawEvents[0].type !== 'start') {
throw new Error('Corrupt run data');
}
// Backfill model/provider on the start event from current defaults if missing,
// then promote to the canonical strict types for callers.
const rawStart = rawEvents[0];
const defaults = (!rawStart.model || !rawStart.provider)
? await getDefaultModelAndProvider()
: null;
const start: z.infer<typeof StartEvent> = {
...rawStart,
model: rawStart.model ?? defaults!.model,
provider: rawStart.provider ?? defaults!.provider,
};
const events: z.infer<typeof RunEvent>[] = [start, ...rawEvents.slice(1) as z.infer<typeof RunEvent>[]];
const title = this.extractTitle(events);
return {
id,
title,
createdAt: events[0].ts!,
agentId: events[0].agentName,
createdAt: start.ts!,
agentId: start.agentName,
model: start.model,
provider: start.provider,
log: events,
};
}

View file

@ -10,11 +10,21 @@ import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
import { extractCommandNames } from "../application/lib/command-executor.js";
import { addToSecurityConfig } from "../config/security.js";
import { loadAgent } from "../agents/runtime.js";
import { getDefaultModelAndProvider } from "../models/defaults.js";
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const repo = container.resolve<IRunsRepo>('runsRepo');
const bus = container.resolve<IBus>('bus');
const run = await repo.create(opts);
// Resolve model+provider once at creation: opts > agent declaration > defaults.
// Both fields are plain strings (provider is a name, looked up at runtime).
const agent = await loadAgent(opts.agentId);
const defaults = await getDefaultModelAndProvider();
const model = opts.model ?? agent.model ?? defaults.model;
const provider = opts.provider ?? agent.provider ?? defaults.provider;
const run = await repo.create({ agentId: opts.agentId, model, provider });
await bus.publish(run.log[0]);
return run;
}
@ -110,4 +120,4 @@ export async function fetchRun(runId: string): Promise<z.infer<typeof Run>> {
export async function listRuns(cursor?: string): Promise<z.infer<typeof ListRunsResponse>> {
const repo = container.resolve<IRunsRepo>('runsRepo');
return repo.list(cursor);
}
}