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:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user