feat(FN-3914): add durable heartbeat model fallback

Adds durable heartbeat model fallback logic to the engine's heartbeat subsystem (`agent-heartbeat.ts`, `agent-session-helpers.ts`, `pi.ts`), allowing heartbeats to gracefully use alternative AI models when the primary model is unavailable, with documentation updates to `agents.md` and `settings-refe

Fusion-Task-Id: FN-3914
This commit is contained in:
Fusion
2026-05-10 02:48:30 -07:00
committed by gsxdsm
parent 44502572fc
commit 26af49a35e
9 changed files with 262 additions and 36 deletions

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { extractRuntimeHint, resolveMergerSessionModel } from "../agent-session-helpers.js";
import {
extractRuntimeHint,
resolveHeartbeatSessionModels,
resolveMergerSessionModel,
} from "../agent-session-helpers.js";
describe("extractRuntimeHint", () => {
it("returns undefined for undefined config", () => {
@@ -23,6 +27,53 @@ describe("extractRuntimeHint", () => {
});
});
describe("resolveHeartbeatSessionModels", () => {
it("uses agent runtime model as primary and execution settings as fallback", () => {
expect(resolveHeartbeatSessionModels(
{
executionProvider: "openai",
executionModelId: "gpt-4.1",
},
{ model: "anthropic/claude-sonnet-4-5" },
)).toEqual({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4.1",
});
});
it("uses execution settings model when runtime override is missing", () => {
expect(resolveHeartbeatSessionModels(
{
executionProvider: "openai",
executionModelId: "gpt-4.1",
},
{},
)).toEqual({
defaultProvider: "openai",
defaultModelId: "gpt-4.1",
fallbackProvider: undefined,
fallbackModelId: undefined,
});
});
it("does not duplicate fallback when runtime and execution model are the same", () => {
expect(resolveHeartbeatSessionModels(
{
executionProvider: "openai",
executionModelId: "gpt-4.1",
},
{ modelProvider: "openai", modelId: "gpt-4.1" },
)).toEqual({
defaultProvider: "openai",
defaultModelId: "gpt-4.1",
fallbackProvider: undefined,
fallbackModelId: undefined,
});
});
});
describe("resolveMergerSessionModel", () => {
it("uses assigned agent runtime model when both provider and modelId are present", () => {
expect(

View File

@@ -2329,9 +2329,15 @@ describe("executeHeartbeat", () => {
expect(taskLogTool.name).toBe("fn_task_log");
});
it("passes model config from agent runtimeConfig to createFnAgent", async () => {
it("passes runtime model as primary and execution settings model as fallback", async () => {
const store = createStoreWithAgentForExec({
runtimeConfig: { modelProvider: "openai", modelId: "gpt-4o" },
runtimeConfig: { model: "anthropic/claude-sonnet-4-5" },
});
mockTaskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({
executionProvider: "openai",
executionModelId: "gpt-4.1",
}),
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({
@@ -2340,12 +2346,14 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.defaultProvider).toBe("openai");
expect(callArgs.defaultModelId).toBe("gpt-4o");
expect(callArgs.defaultProvider).toBe("anthropic");
expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5");
expect(callArgs.fallbackProvider).toBe("openai");
expect(callArgs.fallbackModelId).toBe("gpt-4.1");
});
it("passes undefined model when runtimeConfig has no model", async () => {
@@ -2525,6 +2533,23 @@ describe("executeHeartbeat", () => {
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
});
it("fails soft on timer heartbeat when model provider credentials are unavailable", async () => {
const store = createStoreWithAgentForExec();
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
expect(result.resultJson).toMatchObject({
reason: "heartbeat_model_unavailable",
source: "timer",
});
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
});
it("completes run as failed when promptWithFallback throws", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();

View File

@@ -1050,6 +1050,30 @@ describe("createFnAgent", () => {
expect(createAgentSessionMock).not.toHaveBeenCalled();
});
it("uses the configured fallback model when the primary model cannot be resolved", async () => {
findMock.mockImplementation((provider: string, modelId: string) => {
if (provider === "zai" && modelId === "glm-5.1") return undefined;
return { provider, id: modelId };
});
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "zai",
defaultModelId: "glm-5.1",
fallbackProvider: "openai-codex",
fallbackModelId: "gpt-5.4",
});
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
expect(createAgentSessionMock.mock.calls[0]?.[0]).toMatchObject({
model: { provider: "openai-codex", id: "gpt-5.4" },
});
});
it("throws when the configured fallback model cannot be resolved", async () => {
findMock.mockImplementation((provider: string, modelId: string) => (
provider === "openai-codex" && modelId === "missing-model" ? undefined : { provider, id: modelId }

View File

@@ -33,7 +33,7 @@ import {
import { heartbeatLog, formatError } from "./logger.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
import { promptWithFallback } from "./pi.js";
import { createResolvedAgentSession, extractRuntimeHint, extractRuntimeModel } from "./agent-session-helpers.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveHeartbeatSessionModels } from "./agent-session-helpers.js";
import type { AgentActionGateContext } from "./agent-action-gate.js";
import { buildSessionSkillContextSync } from "./session-skill-context.js";
import type { AgentReflectionService } from "./agent-reflection.js";
@@ -1877,6 +1877,23 @@ export class HeartbeatMonitor {
});
}
const resolveFailSoftProviderError = (errorMessage: string): boolean => {
if (source !== "timer") return false;
const normalized = errorMessage.toLowerCase();
return normalized.includes("no api key for provider")
|| normalized.includes("configured primary model")
|| normalized.includes("was not found in the pi model registry");
};
let heartbeatModelSettings: Settings | undefined;
try {
heartbeatModelSettings = await taskStore.getSettings();
} catch (settingsErr) {
heartbeatLog.warn(`Failed to read heartbeat model settings for ${agentId}: ${settingsErr instanceof Error ? settingsErr.message : String(settingsErr)}`);
}
const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig);
// Create agent session
const { session } = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
@@ -1886,10 +1903,10 @@ export class HeartbeatMonitor {
systemPrompt: systemPromptFinal,
tools: "coding",
customTools: heartbeatTools,
...(() => {
const { provider, modelId } = extractRuntimeModel(agent.runtimeConfig);
return { defaultProvider: provider, defaultModelId: modelId };
})(),
defaultProvider: heartbeatSessionModels.defaultProvider,
defaultModelId: heartbeatSessionModels.defaultModelId,
fallbackProvider: heartbeatSessionModels.fallbackProvider,
fallbackModelId: heartbeatSessionModels.fallbackModelId,
onText: (delta) => {
outputLength += delta.length;
appendStdoutExcerpt(delta);
@@ -2223,11 +2240,25 @@ export class HeartbeatMonitor {
const errorDetail = formatError(err).detail;
heartbeatLog.error(`Heartbeat execution failed for ${agentId}: ${errorDetail}`);
await flushAgentLogger();
await this.completeRun(agentId, run.id, {
status: "failed",
stderrExcerpt: errorDetail,
stdoutExcerpt: stdoutExcerpt || undefined,
});
if (resolveFailSoftProviderError(errorDetail)) {
await this.completeRun(agentId, run.id, {
status: "completed",
resultJson: {
reason: "heartbeat_model_unavailable",
source,
detail: errorDetail,
},
stderrExcerpt: errorDetail,
stdoutExcerpt: stdoutExcerpt || undefined,
});
} else {
await this.completeRun(agentId, run.id, {
status: "failed",
stderrExcerpt: errorDetail,
stdoutExcerpt: stdoutExcerpt || undefined,
});
}
} finally {
await flushAgentLogger();
// Defensively untrack the agent — wrap in try/catch to guarantee cleanup
@@ -2250,14 +2281,31 @@ export class HeartbeatMonitor {
heartbeatLog.error(`Heartbeat execution error for ${agentId}: ${errorDetail}`);
await flushAgentLogger();
// Attempt to complete the run as failed if it's still active.
const normalizedError = errorDetail.toLowerCase();
const shouldFailSoft = source === "timer" && (
normalizedError.includes("no api key for provider")
|| normalizedError.includes("configured primary model")
|| normalizedError.includes("was not found in the pi model registry")
);
// Attempt to complete the run if it's still active.
// If completeRun also fails, fall back to a direct DB update to ensure
// the run is not permanently stuck in "active" state.
try {
await this.completeRun(agentId, run.id, {
status: "failed",
stderrExcerpt: errorDetail,
});
await this.completeRun(agentId, run.id, shouldFailSoft
? {
status: "completed",
resultJson: {
reason: "heartbeat_model_unavailable",
source,
detail: errorDetail,
},
stderrExcerpt: errorDetail,
}
: {
status: "failed",
stderrExcerpt: errorDetail,
});
} catch (completeRunErr) {
const completeRunErrMsg = completeRunErr instanceof Error ? completeRunErr.message : String(completeRunErr);
heartbeatLog.error(`completeRun failed for ${agentId}/${run.id}: ${completeRunErrMsg} — attempting safety-net completion`);

View File

@@ -11,7 +11,12 @@ import type { AgentRuntimeOptions } from "./agent-runtime.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { resolveTaskExecutionModel, resolveTaskPlanningModel, type Settings } from "@fusion/core";
import {
resolveExecutionSettingsModel,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
type Settings,
} from "@fusion/core";
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
import { createLogger } from "./logger.js";
import { promptWithFallback, describeModel } from "./pi.js";
@@ -163,6 +168,33 @@ export function resolvePlanningSessionModel(
};
}
export function resolveHeartbeatSessionModels(
settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
): {
defaultProvider: string | undefined;
defaultModelId: string | undefined;
fallbackProvider: string | undefined;
fallbackModelId: string | undefined;
} {
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
const executionSettingsModel = resolveExecutionSettingsModel(settings);
const defaultProvider = assignedRuntimeModel.provider ?? executionSettingsModel.provider;
const defaultModelId = assignedRuntimeModel.modelId ?? executionSettingsModel.modelId;
const executionPairAvailable = Boolean(executionSettingsModel.provider && executionSettingsModel.modelId);
const defaultMatchesExecution =
defaultProvider === executionSettingsModel.provider && defaultModelId === executionSettingsModel.modelId;
return {
defaultProvider,
defaultModelId,
fallbackProvider: executionPairAvailable && !defaultMatchesExecution ? executionSettingsModel.provider : undefined,
fallbackModelId: executionPairAvailable && !defaultMatchesExecution ? executionSettingsModel.modelId : undefined,
};
}
export function resolveMergerSessionModel(
settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,

View File

@@ -1634,19 +1634,39 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
retry: { enabled: true, maxRetries: 3 },
});
// Resolve explicit model selection if provider and model ID are specified
const selectedModel = resolveConfiguredModel(
modelRegistry,
"primary",
options.defaultProvider,
options.defaultModelId,
);
const fallbackModel = resolveConfiguredModel(
modelRegistry,
"fallback",
options.fallbackProvider,
options.fallbackModelId,
);
// Resolve explicit model selection if provider and model ID are specified.
// If the primary configured model cannot be resolved but a fallback model is
// configured, prefer the fallback as the initial model selection.
let selectedModel;
let fallbackModel;
try {
selectedModel = resolveConfiguredModel(
modelRegistry,
"primary",
options.defaultProvider,
options.defaultModelId,
);
} catch (primaryResolutionError) {
if (!options.fallbackProvider || !options.fallbackModelId) {
throw primaryResolutionError;
}
fallbackModel = resolveConfiguredModel(
modelRegistry,
"fallback",
options.fallbackProvider,
options.fallbackModelId,
);
selectedModel = fallbackModel;
}
if (!fallbackModel) {
fallbackModel = resolveConfiguredModel(
modelRegistry,
"fallback",
options.fallbackProvider,
options.fallbackModelId,
);
}
// Resolve skill selection: explicit skillSelection wins over convenience `skills`
let effectiveSkillSelection: SkillSelectionContext | undefined = options.skillSelection;