mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-13 17:22:37 +02:00
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:
parent
51f2ad6e8a
commit
5c4aa77255
22 changed files with 256 additions and 179 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
53
apps/x/packages/core/src/models/defaults.ts
Normal file
53
apps/x/packages/core/src/models/defaults.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
model: gpt-4.1
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
model: gpt-4.1
|
||||
model: anthropic/claude-haiku-4.5
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue