FN-7719: derive implicit planning fallback model when no fallback configured
Triage planning-model retries no longer dead-end on "no fallback configured" when a provider primary-model call fails (e.g. a 404 wrapped in a 429 for nvidia/moonshotai/kimi-k2.6). - Add resolveImplicitPlanningFallbackModel() to agent-session-helpers.ts: derives a fallback from the resolved project/global default (execution) model when neither planningFallback*/global fallback* is set, guarding against self-swap and skipping in test mode. - Wire the implicit fallback into TriageProcessor.specifyTask() in triage.ts so a retryable primary planner-model failure swaps once via the derived fallback instead of failing triage outright. - Add unit test coverage in agent-session-helpers.test.ts and triage.test.ts for the new implicit-fallback resolution and its triage integration. - Document the new implicit-fallback behavior in docs/settings-reference.md. - Add a patch changeset for @runfusion/fusion describing the fix. Files changed: .changeset/fn-7719-triage-planning-implicit-fallback.md | 7 + docs/settings-reference.md | 2 + packages/engine/src/__tests__/agent-session-helpers.test.ts | 75 +++++++ packages/engine/src/__tests__/triage.test.ts | 237 +++++++++++++++++++++ packages/engine/src/agent-session-helpers.ts | 41 ++++ packages/engine/src/triage.ts | 31 ++- 6 files changed, 389 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7719 Fusion-Task-Lineage: 69e797e1-5bac-47f3-8dce-505b9d64d83c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7719-triage-planning-implicit-fallback.md
Normal file
7
.changeset/fn-7719-triage-planning-implicit-fallback.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Triage recovers automatically when the planning model hits a provider 404/429 and no fallback is set.
|
||||||
|
category: fix
|
||||||
|
dev: TriageProcessor.specifyTask now derives an implicit fallback from the project/global default (execution) model when no planningFallback*/global fallback* pair is configured, so a retryable primary planner-model failure swaps once instead of failing triage with "no fallback configured". Test mode and self-swap are excluded; the single-swap ModelFallbackExhaustedError terminal path is preserved.
|
||||||
@@ -964,6 +964,8 @@ Short-lived token bounds are enforced server-side:
|
|||||||
|
|
||||||
Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models.
|
Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models.
|
||||||
|
|
||||||
|
When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active.
|
||||||
|
|
||||||
Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available.
|
Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available.
|
||||||
|
|
||||||
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure. If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation).
|
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure. If `GROK_API_KEY` is not set in the environment, provider registration falls back to `~/.grok/user-settings.json`'s `apiKey` field (the same file the `grok` CLI itself writes on login) and hydrates `process.env.GROK_API_KEY` from it, so an operator who authenticated via the `grok` CLI but never exported the env var still resolves a key; an already-set env var always wins, and a missing/malformed/empty settings file is fail-soft (no error, no env mutation).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
extractRuntimeModel,
|
extractRuntimeModel,
|
||||||
resolveExecutorSessionModel,
|
resolveExecutorSessionModel,
|
||||||
resolveHeartbeatSessionModels,
|
resolveHeartbeatSessionModels,
|
||||||
|
resolveImplicitPlanningFallbackModel,
|
||||||
resolveMergerSessionModel,
|
resolveMergerSessionModel,
|
||||||
resolvePlanningSessionModel,
|
resolvePlanningSessionModel,
|
||||||
resolveValidatorSessionModel,
|
resolveValidatorSessionModel,
|
||||||
@@ -629,3 +630,77 @@ describe("resolveMergerSessionModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveImplicitPlanningFallbackModel (FN-7719)", () => {
|
||||||
|
it("derives a distinct implicit fallback from the project/global default model", () => {
|
||||||
|
expect(
|
||||||
|
resolveImplicitPlanningFallbackModel(
|
||||||
|
{
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
},
|
||||||
|
"9router",
|
||||||
|
"nvidia/moonshotai/kimi-k2.6",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-4o",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined/undefined when the implicit fallback would equal the primary (self-swap guard)", () => {
|
||||||
|
expect(
|
||||||
|
resolveImplicitPlanningFallbackModel(
|
||||||
|
{
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
},
|
||||||
|
"openai",
|
||||||
|
"gpt-4o",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
provider: undefined,
|
||||||
|
modelId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined/undefined when no project/global default model is configured", () => {
|
||||||
|
expect(
|
||||||
|
resolveImplicitPlanningFallbackModel({}, "9router", "nvidia/moonshotai/kimi-k2.6"),
|
||||||
|
).toEqual({
|
||||||
|
provider: undefined,
|
||||||
|
modelId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject an implicit fallback in test mode", () => {
|
||||||
|
expect(
|
||||||
|
resolveImplicitPlanningFallbackModel(
|
||||||
|
{
|
||||||
|
testMode: true,
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
},
|
||||||
|
"9router",
|
||||||
|
"nvidia/moonshotai/kimi-k2.6",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
provider: undefined,
|
||||||
|
modelId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the assigned agent runtime model when no default model pair is configured", () => {
|
||||||
|
expect(
|
||||||
|
resolveImplicitPlanningFallbackModel(
|
||||||
|
{},
|
||||||
|
"9router",
|
||||||
|
"nvidia/moonshotai/kimi-k2.6",
|
||||||
|
{ model: "anthropic/claude-3-5-sonnet-20241022" },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "claude-3-5-sonnet-20241022",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4263,6 +4263,243 @@ describe("taskCreate tool model inheritance", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("implicit planning fallback (FN-7719)", () => {
|
||||||
|
const baseSession = () => ({
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
sessionManager: {
|
||||||
|
getLeafId: vi.fn().mockReturnValue(null),
|
||||||
|
navigateTree: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers from the reported 404/429 planner failure via a derived implicit fallback", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "FN-7719",
|
||||||
|
description: "Bug: 9router/Planning 404 on nvidia/moonshotai/kimi-k2.6",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as unknown as Task;
|
||||||
|
const onSpecifyError = vi.fn();
|
||||||
|
const store = createMockStore({
|
||||||
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 10000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
// Primary planner lane ("9router/Planning") — distinct from the project default.
|
||||||
|
planningProvider: "9router",
|
||||||
|
planningModelId: "nvidia/moonshotai/kimi-k2.6",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
// No planningFallback*/global fallback* configured — this is the reported gap.
|
||||||
|
defaultThinkingLevel: "low",
|
||||||
|
} as Settings),
|
||||||
|
});
|
||||||
|
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
|
||||||
|
|
||||||
|
const { promptWithFallback } = await import("../pi.js");
|
||||||
|
// With a distinct implicit fallback now supplied, pi.ts's real single-swap
|
||||||
|
// loop (covered by pi.test.ts) recovers instead of throwing
|
||||||
|
// ModelFallbackExhaustedError — simulate that recovered outcome here.
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, "/test/root", {
|
||||||
|
pollIntervalMs: 100_000,
|
||||||
|
onSpecifyError,
|
||||||
|
});
|
||||||
|
|
||||||
|
await processor.specifyTask(task);
|
||||||
|
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
defaultProvider: "9router",
|
||||||
|
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
|
||||||
|
fallbackProvider: "openai",
|
||||||
|
fallbackModelId: "gpt-4o",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-7719", expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
error: expect.stringContaining("no fallback configured"),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays terminal when the implicit fallback would equal the primary planner model (self-swap guard)", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "FN-7719-SELF-SWAP",
|
||||||
|
description: "No distinct default model available for implicit fallback",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as unknown as Task;
|
||||||
|
const onSpecifyError = vi.fn();
|
||||||
|
const store = createMockStore({
|
||||||
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 10000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
// No planningProvider/planningModelId — the primary planning model
|
||||||
|
// resolves through to the project default itself, so the implicit
|
||||||
|
// fallback would equal the primary. Must NOT self-swap.
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
defaultThinkingLevel: "low",
|
||||||
|
} as Settings),
|
||||||
|
});
|
||||||
|
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
|
||||||
|
|
||||||
|
const { ModelFallbackExhaustedError, promptWithFallback } = await import("../pi.js");
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new ModelFallbackExhaustedError({
|
||||||
|
primaryModel: "openai/gpt-4o",
|
||||||
|
triggerPoint: "prompt-time",
|
||||||
|
attempts: 1,
|
||||||
|
underlyingReason: "model not found: no distinct fallback available",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, "/test/root", {
|
||||||
|
pollIntervalMs: 100_000,
|
||||||
|
onSpecifyError,
|
||||||
|
});
|
||||||
|
|
||||||
|
await processor.specifyTask(task);
|
||||||
|
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
fallbackProvider: undefined,
|
||||||
|
fallbackModelId: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-7719-SELF-SWAP", expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
recoveryRetryCount: null,
|
||||||
|
nextRecoveryAt: null,
|
||||||
|
}));
|
||||||
|
expect(onSpecifyError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override an explicitly configured planningFallback* pair", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "FN-7719-EXPLICIT-PLANNING",
|
||||||
|
description: "Explicit planning fallback stays authoritative",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as unknown as Task;
|
||||||
|
const store = createMockStore({
|
||||||
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 10000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
planningProvider: "9router",
|
||||||
|
planningModelId: "nvidia/moonshotai/kimi-k2.6",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
planningFallbackProvider: "anthropic",
|
||||||
|
planningFallbackModelId: "claude-3-5-haiku-20241022",
|
||||||
|
} as Settings),
|
||||||
|
});
|
||||||
|
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
|
||||||
|
|
||||||
|
const { promptWithFallback } = await import("../pi.js");
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error("test stop after model check"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
|
||||||
|
await processor.specifyTask(task);
|
||||||
|
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
defaultProvider: "9router",
|
||||||
|
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
|
||||||
|
fallbackProvider: "anthropic",
|
||||||
|
fallbackModelId: "claude-3-5-haiku-20241022",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override an explicitly configured global fallback* pair", async () => {
|
||||||
|
const task = {
|
||||||
|
id: "FN-7719-EXPLICIT-GLOBAL",
|
||||||
|
description: "Explicit global fallback stays authoritative",
|
||||||
|
column: "triage",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as unknown as Task;
|
||||||
|
const store = createMockStore({
|
||||||
|
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 10000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
planningProvider: "9router",
|
||||||
|
planningModelId: "nvidia/moonshotai/kimi-k2.6",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4o",
|
||||||
|
fallbackProvider: "google",
|
||||||
|
fallbackModelId: "gemini-2.5-pro",
|
||||||
|
} as Settings),
|
||||||
|
});
|
||||||
|
mockCreateFnAgent.mockResolvedValue({ session: baseSession() });
|
||||||
|
|
||||||
|
const { promptWithFallback } = await import("../pi.js");
|
||||||
|
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error("test stop after model check"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 });
|
||||||
|
await processor.specifyTask(task);
|
||||||
|
|
||||||
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
defaultProvider: "9router",
|
||||||
|
defaultModelId: "nvidia/moonshotai/kimi-k2.6",
|
||||||
|
fallbackProvider: "google",
|
||||||
|
fallbackModelId: "gemini-2.5-pro",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: test-mode exclusion (isTestModeActive -> no implicit fallback
|
||||||
|
// injected) is covered directly at the resolver-unit level in
|
||||||
|
// agent-session-helpers.test.ts ("resolveImplicitPlanningFallbackModel
|
||||||
|
// (FN-7719)"). The mock runtime used by createResolvedAgentSession in
|
||||||
|
// test mode does not route through createFnAgent, so it cannot assert
|
||||||
|
// fallbackProvider/fallbackModelId via mockCreateFnAgent call args here.
|
||||||
|
});
|
||||||
|
|
||||||
it("escalates to error state when triage retries are exhausted via specifyTask", async () => {
|
it("escalates to error state when triage retries are exhausted via specifyTask", async () => {
|
||||||
const task = {
|
const task = {
|
||||||
id: "FN-201",
|
id: "FN-201",
|
||||||
|
|||||||
@@ -212,6 +212,47 @@ export function resolvePlanningSessionModel(
|
|||||||
return pickSettingsThenRuntimeModel(resolvedTaskPlanningModel, assignedAgentRuntimeConfig);
|
return pickSettingsThenRuntimeModel(resolvedTaskPlanningModel, assignedAgentRuntimeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:TriageModelFallback 2026-07-09-00:00:
|
||||||
|
* When no explicit `planningFallback*`/global `fallback*` pair is configured, the
|
||||||
|
* planning lane must still get a working fallback. Derive one implicitly from the
|
||||||
|
* resolved project/global default (execution) model — the same resolver
|
||||||
|
* `resolveHeartbeatSessionModels`/`resolveMergerSessionModel` use — so a retryable
|
||||||
|
* primary-planner failure (e.g. provider 404/429) recovers via one distinct swap
|
||||||
|
* instead of failing triage permanently (see FN-7719). Guard against self-swap
|
||||||
|
* (implicit fallback === primary planning model) and skip entirely in test mode,
|
||||||
|
* so the single-swap `usingFallback` ceiling in pi.ts and the terminal
|
||||||
|
* ModelFallbackExhaustedError path are preserved unchanged.
|
||||||
|
*/
|
||||||
|
export function resolveImplicitPlanningFallbackModel(
|
||||||
|
settings: Partial<Settings> | undefined,
|
||||||
|
primaryProvider: string | undefined,
|
||||||
|
primaryModelId: string | undefined,
|
||||||
|
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||||
|
): { provider: string | undefined; modelId: string | undefined } {
|
||||||
|
if (isTestModeActive(settings)) {
|
||||||
|
return { provider: undefined, modelId: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultModel = resolveProjectDefaultModel(settings);
|
||||||
|
const resolvedModel = pickSettingsThenRuntimeModel(defaultModel, assignedAgentRuntimeConfig);
|
||||||
|
|
||||||
|
if (!resolvedModel.provider || !resolvedModel.modelId) {
|
||||||
|
return { provider: undefined, modelId: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-swap guard: an implicit fallback identical to the primary planner
|
||||||
|
// model would produce a misleading "fallback configured" message while
|
||||||
|
// still hitting the terminal ModelFallbackExhaustedError path in pi.ts
|
||||||
|
// (hasDistinctFallback requires the models to differ). Leave both fields
|
||||||
|
// undefined so the existing terminal behavior is preserved cleanly.
|
||||||
|
if (resolvedModel.provider === primaryProvider && resolvedModel.modelId === primaryModelId) {
|
||||||
|
return { provider: undefined, modelId: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedModel;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveValidatorSessionModel(
|
export function resolveValidatorSessionModel(
|
||||||
taskValidatorModelProvider: string | undefined,
|
taskValidatorModelProvider: string | undefined,
|
||||||
taskValidatorModelId: string | undefined,
|
taskValidatorModelId: string | undefined,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, p
|
|||||||
import {
|
import {
|
||||||
createResolvedAgentSession,
|
createResolvedAgentSession,
|
||||||
extractRuntimeHint,
|
extractRuntimeHint,
|
||||||
|
resolveImplicitPlanningFallbackModel,
|
||||||
resolvePlanningSessionModel,
|
resolvePlanningSessionModel,
|
||||||
} from "./agent-session-helpers.js";
|
} from "./agent-session-helpers.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||||
@@ -1117,6 +1118,28 @@ export class TriageProcessor {
|
|||||||
defaultModelId: planningModel.modelId,
|
defaultModelId: planningModel.modelId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:TriageModelFallback 2026-07-09-00:00:
|
||||||
|
* When neither `planningFallback*` nor global `fallback*` is configured,
|
||||||
|
* derive an implicit fallback from the project/global default (execution)
|
||||||
|
* model so a retryable primary-planner failure (e.g. provider 404/429)
|
||||||
|
* recovers via one distinct swap instead of failing triage permanently
|
||||||
|
* (FN-7719: nvidia/moonshotai/kimi-k2.6 404 wrapped in a 429 stalled a
|
||||||
|
* whole board's triage with "no fallback configured"). Self-swap (implicit
|
||||||
|
* fallback === primary) and test mode are excluded so the single-swap,
|
||||||
|
* no-loop invariant and the mock lane stay unchanged.
|
||||||
|
*/
|
||||||
|
const hasExplicitPlanningFallback = Boolean(settings.planningFallbackProvider && settings.planningFallbackModelId);
|
||||||
|
const hasExplicitGlobalFallback = Boolean(settings.fallbackProvider && settings.fallbackModelId);
|
||||||
|
const implicitPlanningFallback = (!hasExplicitPlanningFallback && !hasExplicitGlobalFallback)
|
||||||
|
? resolveImplicitPlanningFallbackModel(
|
||||||
|
settings,
|
||||||
|
planningModel.provider,
|
||||||
|
planningModel.modelId,
|
||||||
|
assignedAgent?.runtimeConfig,
|
||||||
|
)
|
||||||
|
: { provider: undefined, modelId: undefined };
|
||||||
|
|
||||||
const { session } = await createResolvedAgentSession({
|
const { session } = await createResolvedAgentSession({
|
||||||
sessionPurpose: "triage",
|
sessionPurpose: "triage",
|
||||||
runtimeHint: triageRuntimeHint,
|
runtimeHint: triageRuntimeHint,
|
||||||
@@ -1131,12 +1154,12 @@ export class TriageProcessor {
|
|||||||
onToolStart: agentLogger.onToolStart,
|
onToolStart: agentLogger.onToolStart,
|
||||||
onToolEnd: agentLogger.onToolEnd,
|
onToolEnd: agentLogger.onToolEnd,
|
||||||
...planningSessionModelOptions,
|
...planningSessionModelOptions,
|
||||||
fallbackProvider: settings.planningFallbackProvider && settings.planningFallbackModelId
|
fallbackProvider: hasExplicitPlanningFallback
|
||||||
? settings.planningFallbackProvider
|
? settings.planningFallbackProvider
|
||||||
: settings.fallbackProvider,
|
: (hasExplicitGlobalFallback ? settings.fallbackProvider : implicitPlanningFallback.provider),
|
||||||
fallbackModelId: settings.planningFallbackProvider && settings.planningFallbackModelId
|
fallbackModelId: hasExplicitPlanningFallback
|
||||||
? settings.planningFallbackModelId
|
? settings.planningFallbackModelId
|
||||||
: settings.fallbackModelId,
|
: (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId),
|
||||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
runAuditor,
|
runAuditor,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
Reference in New Issue
Block a user