diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 421304ea..8f7f6df6 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -254,17 +254,22 @@ export function createHubChatPlatform( } /** - * A definition that declares no model of its own resolves the same - * catalog default at every deploy that `launchInvite` used to resolve - * at launch time — every deploy of such a run now goes through a wake - * or a relaunch (launches mint only), and a slept one always did. + * The tenant's current live default model — always resolved, whether + * or not `binding.foldedBody.model` names one of its own. A + * definition that declares no model resolves this same catalog + * default at every deploy, exactly as `launchInvite` used to resolve + * it at launch time (every deploy of such a run now goes through a + * wake or a relaunch — launches mint only — and a slept one always + * did). A definition that DOES name a model still needs this: it is + * `deployAtHead`'s retry target when that pinned model no longer + * resolves against the tenant's current catalog (a provider it was + * pinned against got disconnected, or none was connected yet when it + * was pinned) — so reconnecting a different provider heals an + * already-invited agent, not only a freshly invited one. */ async function resolveFallbackModel( binding: AgentBinding, ): Promise { - if (binding.foldedBody.model !== null) { - return undefined; - } const preferences = (await deps.workbenchHostInferencePreferences?.(binding.tenantId)) ?? []; return preferences[0]?.model; diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 1fa51710..d6cfae3b 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -231,14 +231,21 @@ export async function deployAtHead( */ sources?: SourcesOverride; /** - * The model to resolve against when `foldedBody.model` is `null` — - * a definition that declares no model requirements of its own - * (e.g. a hand-authored agent created without a `model`). Absent, - * a `null` `foldedBody.model` resolves to the loud - * `InferenceResolutionError` it always has; present, it is what - * lets that same definition still launch by falling back to the - * caller's own tenant-default resolution instead of 409ing. Never - * consulted when `foldedBody.model` is already set. + * The tenant's current live default model — the same (provider, + * model) Settings' "Default model & fallbacks" row heads today + * (see `@corbits/chat`'s `resolveFallbackModel`). Tried first when + * `foldedBody.model` is `null` (a definition that declares no model + * of its own); tried SECOND, as a retry, when `foldedBody.model` is + * set but does not resolve against the tenant's current catalog — + * e.g. it was pinned (by a person, or by the seed that created this + * definition) against a provider the tenant has since disconnected, + * or before it connected any provider at all. Without that retry an + * agent whose pin predates the tenant's current connection stays + * permanently dead even after a working provider is connected, + * because `foldedBody.model` alone would keep resolving to the same + * unlaunchable name forever. Absent, a `foldedBody.model` that fails + * to resolve — or a `null` one with nothing to fall back to — + * resolves to the loud `InferenceResolutionError` it always has. */ fallbackModel?: string; /** @@ -252,20 +259,45 @@ export async function deployAtHead( }, ): Promise { const sourcesOverride = parseSourcesOverride(params.sources); - const resolution = + const resolveAgainst = (model: string | null) => + resolveDefinitionSources({ + db: deps.db, + tenantId: params.tenantId, + modelRequirements: null, + fallbackModel: model, + invokerPreferences: {}, + ...(deps.credentialCipher !== undefined + ? { credentialCipher: deps.credentialCipher } + : {}), + }); + + const definitionModel = params.foldedBody.model; + let resolution = sourcesOverride !== undefined ? { ok: true as const, ...sourcesOverride } - : await resolveDefinitionSources({ - db: deps.db, - tenantId: params.tenantId, - modelRequirements: null, - fallbackModel: - params.foldedBody.model ?? params.fallbackModel ?? null, - invokerPreferences: {}, - ...(deps.credentialCipher !== undefined - ? { credentialCipher: deps.credentialCipher } - : {}), - }); + : await resolveAgainst(definitionModel ?? params.fallbackModel ?? null); + + // The definition's own pinned model failed to resolve — it may have + // been pinned (by a person, or by the seed that created this + // definition) against a provider the tenant has since disconnected, + // or before it connected any provider at all. Retry once against the + // tenant's current live default rather than leaving the agent + // permanently dead: reconnecting a DIFFERENT provider must heal every + // agent already pinned to the old one, not only a freshly created + // one. Never retried for a caller-supplied override (nothing to + // retry against — the tenant catalog is never touched for those) or + // when the definition already declared no model of its own (that + // case already tried `fallbackModel` as its one and only attempt). + if ( + !resolution.ok && + sourcesOverride === undefined && + definitionModel !== null && + params.fallbackModel !== undefined && + params.fallbackModel !== definitionModel + ) { + resolution = await resolveAgainst(params.fallbackModel); + } + if (!resolution.ok) { throw new InferenceResolutionError(params.launchLabel, resolution.message); } diff --git a/packages/folded-runs/test/launch.test.ts b/packages/folded-runs/test/launch.test.ts index 991849f1..66f5d57a 100644 --- a/packages/folded-runs/test/launch.test.ts +++ b/packages/folded-runs/test/launch.test.ts @@ -38,12 +38,21 @@ let resolveDefinitionSourcesResult: DefinitionSourceResolution = { defaultSource: "off_1", }; const resolveDefinitionSourcesCalls: unknown[] = []; +// A queued sequence of per-call results, consumed oldest-first, for a +// test that must prove `deployAtHead` retries with a SECOND fallback +// model after the first attempt fails — a single shared +// `resolveDefinitionSourcesResult` cannot express "fails, then +// succeeds" across two calls inside the same synchronous resolution. +// Empty (the common case) falls back to the single shared result +// above, unchanged for every other test in this file. +let resolveDefinitionSourcesQueue: DefinitionSourceResolution[] = []; mock.module("@intx/hub-api", () => ({ ...actualHubApi, resolveDefinitionSources: async (...args: unknown[]) => { resolveDefinitionSourcesCalls.push(args[0]); - return resolveDefinitionSourcesResult; + const queued = resolveDefinitionSourcesQueue.shift(); + return queued ?? resolveDefinitionSourcesResult; }, })); @@ -886,6 +895,113 @@ describe("launchFoldedRun", () => { expect(err.message).toMatch(/the workbench host/); }); + // The reproduction this fixes: a definition pinned to a model from a + // provider the tenant has since disconnected (or one baked in before + // any provider was connected at all) must not stay permanently dead + // once a DIFFERENT provider is connected. `deployAtHead` retries once + // against `fallbackModel` — the tenant's current live default — when + // the definition's own pinned model fails to resolve. + test("retries against fallbackModel when the definition's pinned model has no launchable source", async () => { + resolveDefinitionSourcesCalls.length = 0; + resolveDefinitionSourcesQueue = [ + { + ok: false, + message: 'No launchable inference source for model "claude-sonnet-5"', + }, + { + ok: true, + sources: [ + { + id: "off_ollama", + provider: "ollama", + baseURL: "https://home-mac-studio.tail87f5aa.ts.net/v1", + apiKey: "placeholder", + model: "gpt-oss:20b", + }, + ], + defaultSource: "off_ollama", + }, + ]; + + const db = createFakeDb(); + const sessionService = createFakeSessionService(); + + const result = await launchFoldedRun( + { + db: db as never, + sessionService, + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter(), + toolGrantsForPins: () => [], + eventCollectors: createFakeEventCollectors(), + }, + { + tenantId: "ten_1", + instanceId: "ins_workbench1", + triggerAddress: "ins_workbench1@ten1.workbench.test", + definitionId: "wfd_workbench1", + // Pinned to a model the tenant no longer (or never did) has a + // credential for. + foldedBody: FOLDED_BODY, + launchLabel: "the workbench host", + // The tenant's current live default, e.g. the connected Ollama + // instance's own resolved model. + fallbackModel: "gpt-oss:20b", + }, + ); + + expect(result.sessionId).toBeTruthy(); + expect(resolveDefinitionSourcesCalls).toHaveLength(2); + expect(resolveDefinitionSourcesCalls[0]).toMatchObject({ + fallbackModel: "claude-sonnet-5", + }); + expect(resolveDefinitionSourcesCalls[1]).toMatchObject({ + fallbackModel: "gpt-oss:20b", + }); + + const deployed = onlyCall(sessionService.adoptedDeployCalls); + expect(deployed.config.defaultSource).toBe("off_ollama"); + }); + + test("does not retry when fallbackModel is absent or matches the definition's own pinned model", async () => { + resolveDefinitionSourcesCalls.length = 0; + resolveDefinitionSourcesQueue = []; + resolveDefinitionSourcesResult = { + ok: false, + message: 'No launchable inference source for model "claude-sonnet-5"', + }; + + const db = createFakeDb(); + let caught: unknown; + try { + await launchFoldedRun( + { + db: db as never, + sessionService: createFakeSessionService(), + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter(), + toolGrantsForPins: () => [], + eventCollectors: createFakeEventCollectors(), + }, + { + tenantId: "ten_1", + instanceId: "ins_workbench1", + triggerAddress: "ins_workbench1@ten1.workbench.test", + definitionId: "wfd_workbench1", + foldedBody: FOLDED_BODY, + launchLabel: "the workbench host", + // Same name as the definition's own pin -- nothing new to try. + fallbackModel: "claude-sonnet-5", + }, + ); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(InferenceResolutionError); + expect(resolveDefinitionSourcesCalls).toHaveLength(1); + }); + test("uses a caller-supplied sources override verbatim, never touching the catalog", async () => { resolveDefinitionSourcesCalls.length = 0; // Forced to fail if ever consulted: proves the override path skips