FN-6219: prefer fresh agent model settings for automatic runs
Automatic agent runs now resolve lane-specific task and settings models before falling back to durable runtime defaults. - Prefer fresh execution, planning, heartbeat, merger, and validator model resolution over stale assigned-agent runtime config when complete settings are available. - Centralize validator session model resolution and align mission validation with the shared helper. - Add regression coverage for automatic run model precedence and test-mode handling. - Add a patch changeset for the published CLI package. Files changed: .changeset/fuzzy-agents-run.md | 5 + .../core/src/__tests__/model-resolution.test.ts | 40 +++++ .../agent-session-helpers-test-mode.test.ts | 35 ++-- .../src/__tests__/agent-session-helpers.test.ts | 199 ++++++++++++++++++--- .../src/__tests__/heartbeat-executor.test.ts | 10 +- .../src/__tests__/mission-execution-loop.test.ts | 6 +- packages/engine/src/__tests__/triage.test.ts | 4 +- packages/engine/src/agent-session-helpers.ts | 111 +++++++----- packages/engine/src/mission-execution-loop.ts | 33 +--- packages/engine/src/triage.ts | 6 +- 10 files changed, 326 insertions(+), 123 deletions(-) Fusion-Task-Id: FN-6219 Fusion-Task-Lineage: c3ef6d91-234f-4ed1-97c7-9be8408cd86c
This commit is contained in:
5
.changeset/fuzzy-agents-run.md
Normal file
5
.changeset/fuzzy-agents-run.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix automatic agent runs to resolve executor, planning, heartbeat, merger, and validator models from fresh task/settings configuration before falling back to durable agent runtime defaults.
|
||||||
@@ -119,6 +119,46 @@ describe("model-resolution", () => {
|
|||||||
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
|
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores partial pairs at every precedence tier", () => {
|
||||||
|
expect(
|
||||||
|
resolveProjectDefaultModel({
|
||||||
|
defaultProviderOverride: "openai",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
}),
|
||||||
|
).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveTaskExecutionModel(
|
||||||
|
{ modelProvider: "task-provider" },
|
||||||
|
{
|
||||||
|
executionProvider: "openai",
|
||||||
|
executionModelId: "gpt-4.1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({ provider: "openai", modelId: "gpt-4.1" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveTaskPlanningModel(
|
||||||
|
{ planningModelId: "task-planning-model" },
|
||||||
|
{
|
||||||
|
planningGlobalProvider: "anthropic",
|
||||||
|
planningGlobalModelId: "claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveTaskValidatorModel(
|
||||||
|
{ validatorModelProvider: "validator-task-provider" },
|
||||||
|
{
|
||||||
|
defaultProviderOverride: "google",
|
||||||
|
defaultModelIdOverride: "gemini-2.5-pro",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
|
||||||
|
});
|
||||||
|
|
||||||
it("forces every lane to mock when testMode is true", () => {
|
it("forces every lane to mock when testMode is true", () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
testMode: true,
|
testMode: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
resolveHeartbeatSessionModels,
|
resolveHeartbeatSessionModels,
|
||||||
resolveMergerSessionModel,
|
resolveMergerSessionModel,
|
||||||
resolvePlanningSessionModel,
|
resolvePlanningSessionModel,
|
||||||
|
resolveValidatorSessionModel,
|
||||||
} from "../agent-session-helpers.js";
|
} from "../agent-session-helpers.js";
|
||||||
|
|
||||||
const assignedAgentRuntimeConfig = {
|
const assignedAgentRuntimeConfig = {
|
||||||
@@ -33,6 +34,10 @@ describe("agent-session-helpers test mode overrides", () => {
|
|||||||
provider: "mock",
|
provider: "mock",
|
||||||
modelId: "scripted",
|
modelId: "scripted",
|
||||||
});
|
});
|
||||||
|
expect(resolveValidatorSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
|
provider: "mock",
|
||||||
|
modelId: "scripted",
|
||||||
|
});
|
||||||
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
|
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
modelId: "scripted",
|
modelId: "scripted",
|
||||||
@@ -65,6 +70,10 @@ describe("agent-session-helpers test mode overrides", () => {
|
|||||||
provider: "mock",
|
provider: "mock",
|
||||||
modelId: "scripted",
|
modelId: "scripted",
|
||||||
});
|
});
|
||||||
|
expect(resolveValidatorSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
|
provider: "mock",
|
||||||
|
modelId: "scripted",
|
||||||
|
});
|
||||||
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
|
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
modelId: "scripted",
|
modelId: "scripted",
|
||||||
@@ -77,7 +86,7 @@ describe("agent-session-helpers test mode overrides", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps existing behavior when test mode is inactive", () => {
|
it("prefers freshly resolved settings over runtimeConfig when test mode is inactive", () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
executionProvider: "openai",
|
executionProvider: "openai",
|
||||||
executionModelId: "gpt-4.1",
|
executionModelId: "gpt-4.1",
|
||||||
@@ -90,22 +99,26 @@ describe("agent-session-helpers test mode overrides", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(resolveExecutorSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
|
expect(resolveExecutorSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
provider: "anthropic",
|
provider: "task-provider",
|
||||||
modelId: "claude-sonnet-4-5",
|
modelId: "task-model",
|
||||||
});
|
});
|
||||||
expect(resolvePlanningSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
|
expect(resolvePlanningSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
provider: "anthropic",
|
provider: "task-provider",
|
||||||
modelId: "claude-sonnet-4-5",
|
modelId: "task-model",
|
||||||
|
});
|
||||||
|
expect(resolveValidatorSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
|
provider: "task-provider",
|
||||||
|
modelId: "task-model",
|
||||||
});
|
});
|
||||||
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
|
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
provider: "anthropic",
|
provider: "openai",
|
||||||
modelId: "claude-sonnet-4-5",
|
modelId: "gpt-4.1",
|
||||||
});
|
});
|
||||||
expect(resolveHeartbeatSessionModels(settings, assignedAgentRuntimeConfig)).toEqual({
|
expect(resolveHeartbeatSessionModels(settings, assignedAgentRuntimeConfig)).toEqual({
|
||||||
defaultProvider: "anthropic",
|
defaultProvider: "openai",
|
||||||
defaultModelId: "claude-sonnet-4-5",
|
defaultModelId: "gpt-4.1",
|
||||||
fallbackProvider: "openai",
|
fallbackProvider: undefined,
|
||||||
fallbackModelId: "gpt-4.1",
|
fallbackModelId: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
extractRuntimeHint,
|
extractRuntimeHint,
|
||||||
|
extractRuntimeModel,
|
||||||
|
resolveExecutorSessionModel,
|
||||||
resolveHeartbeatSessionModels,
|
resolveHeartbeatSessionModels,
|
||||||
resolveMergerSessionModel,
|
resolveMergerSessionModel,
|
||||||
|
resolvePlanningSessionModel,
|
||||||
|
resolveValidatorSessionModel,
|
||||||
} from "../agent-session-helpers.js";
|
} from "../agent-session-helpers.js";
|
||||||
|
|
||||||
const { resolveRuntimeMock } = vi.hoisted(() => ({
|
const { resolveRuntimeMock } = vi.hoisted(() => ({
|
||||||
@@ -39,49 +43,199 @@ describe("extractRuntimeHint", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveHeartbeatSessionModels", () => {
|
describe("extractRuntimeModel", () => {
|
||||||
it("uses agent runtime model as primary and execution settings as fallback", () => {
|
it("parses combined and separate runtime model pairs", () => {
|
||||||
|
expect(extractRuntimeModel({ model: " anthropic/claude-sonnet-4-5 " })).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
});
|
||||||
|
expect(extractRuntimeModel({ modelProvider: " openai ", modelId: " gpt-4.1 " })).toEqual({
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-4.1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not turn malformed combined strings into complete model pairs", () => {
|
||||||
|
expect(extractRuntimeModel({ model: "gpt 5.3" })).toEqual({
|
||||||
|
provider: undefined,
|
||||||
|
modelId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolve session model parity", () => {
|
||||||
|
const settings = {
|
||||||
|
executionProvider: "openai",
|
||||||
|
executionModelId: "gpt-4.1",
|
||||||
|
planningProvider: "anthropic",
|
||||||
|
planningModelId: "claude-sonnet-4-5",
|
||||||
|
defaultProviderOverride: "google",
|
||||||
|
defaultModelIdOverride: "gemini-2.5-pro",
|
||||||
|
defaultProvider: "zai",
|
||||||
|
defaultModelId: "glm-5.1",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("uses the same fresh settings model for executor and heartbeat when runtimeConfig is absent", () => {
|
||||||
|
const executor = resolveExecutorSessionModel(undefined, undefined, settings);
|
||||||
|
const heartbeat = resolveHeartbeatSessionModels(settings);
|
||||||
|
|
||||||
|
expect(executor).toEqual({ provider: "openai", modelId: "gpt-4.1" });
|
||||||
|
expect(heartbeat).toEqual({
|
||||||
|
defaultProvider: executor.provider,
|
||||||
|
defaultModelId: executor.modelId,
|
||||||
|
fallbackProvider: undefined,
|
||||||
|
fallbackModelId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores partial runtimeConfig pairs without mixing runtime and settings fields", () => {
|
||||||
expect(resolveHeartbeatSessionModels(
|
expect(resolveHeartbeatSessionModels(
|
||||||
{
|
{
|
||||||
executionProvider: "openai",
|
executionProvider: "openai",
|
||||||
executionModelId: "gpt-4.1",
|
executionModelId: "gpt-4.1",
|
||||||
},
|
},
|
||||||
{ model: "anthropic/claude-sonnet-4-5" },
|
{ modelProvider: "stale-provider" },
|
||||||
)).toEqual({
|
)).toEqual({
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4.1",
|
||||||
|
fallbackProvider: undefined,
|
||||||
|
fallbackModelId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveHeartbeatSessionModels(
|
||||||
|
{
|
||||||
|
executionProvider: "openai",
|
||||||
|
executionModelId: "gpt-4.1",
|
||||||
|
},
|
||||||
|
{ modelId: "gpt 5.3" },
|
||||||
|
)).toEqual({
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4.1",
|
||||||
|
fallbackProvider: undefined,
|
||||||
|
fallbackModelId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a stale complete runtime model mask newer task or settings models", () => {
|
||||||
|
const staleRuntimeConfig = { model: "openai-codex/gpt-5.3-codex" };
|
||||||
|
|
||||||
|
expect(resolveExecutorSessionModel("task-provider", "task-model", settings, staleRuntimeConfig)).toEqual({
|
||||||
|
provider: "task-provider",
|
||||||
|
modelId: "task-model",
|
||||||
|
});
|
||||||
|
expect(resolveExecutorSessionModel(undefined, undefined, settings, staleRuntimeConfig)).toEqual({
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-4.1",
|
||||||
|
});
|
||||||
|
expect(resolvePlanningSessionModel("planning-task-provider", "planning-task-model", settings, staleRuntimeConfig)).toEqual({
|
||||||
|
provider: "planning-task-provider",
|
||||||
|
modelId: "planning-task-model",
|
||||||
|
});
|
||||||
|
expect(resolvePlanningSessionModel(undefined, undefined, settings, staleRuntimeConfig)).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
});
|
||||||
|
expect(resolveHeartbeatSessionModels(settings, staleRuntimeConfig)).toEqual({
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4.1",
|
||||||
|
fallbackProvider: undefined,
|
||||||
|
fallbackModelId: undefined,
|
||||||
|
});
|
||||||
|
expect(resolveValidatorSessionModel("validator-task-provider", "validator-task-model", settings, staleRuntimeConfig)).toEqual({
|
||||||
|
provider: "validator-task-provider",
|
||||||
|
modelId: "validator-task-model",
|
||||||
|
});
|
||||||
|
expect(resolveMergerSessionModel(settings, staleRuntimeConfig)).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
modelId: "gemini-2.5-pro",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak malformed gpt 5.3-style runtimeConfig into any automatic lane", () => {
|
||||||
|
const malformedRuntimeConfig = { modelId: "gpt 5.3" };
|
||||||
|
|
||||||
|
expect(resolveExecutorSessionModel(undefined, undefined, settings, malformedRuntimeConfig)).toEqual({
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-4.1",
|
||||||
|
});
|
||||||
|
expect(resolvePlanningSessionModel(undefined, undefined, settings, malformedRuntimeConfig)).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
});
|
||||||
|
expect(resolveHeartbeatSessionModels(settings, malformedRuntimeConfig)).toEqual({
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModelId: "gpt-4.1",
|
||||||
|
fallbackProvider: undefined,
|
||||||
|
fallbackModelId: undefined,
|
||||||
|
});
|
||||||
|
expect(resolveValidatorSessionModel(undefined, undefined, settings, malformedRuntimeConfig)).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
modelId: "gemini-2.5-pro",
|
||||||
|
});
|
||||||
|
expect(resolveMergerSessionModel(settings, malformedRuntimeConfig)).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
modelId: "gemini-2.5-pro",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back through project override and global defaults before runtimeConfig", () => {
|
||||||
|
const staleRuntimeConfig = { model: "stale-provider/stale-model" };
|
||||||
|
|
||||||
|
expect(resolveMergerSessionModel({
|
||||||
|
defaultProviderOverride: "google",
|
||||||
|
defaultModelIdOverride: "gemini-2.5-pro",
|
||||||
defaultProvider: "anthropic",
|
defaultProvider: "anthropic",
|
||||||
defaultModelId: "claude-sonnet-4-5",
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
fallbackProvider: "openai",
|
}, staleRuntimeConfig)).toEqual({ provider: "google", modelId: "gemini-2.5-pro" });
|
||||||
fallbackModelId: "gpt-4.1",
|
expect(resolveMergerSessionModel({
|
||||||
});
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-sonnet-4-5",
|
||||||
|
}, staleRuntimeConfig)).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses execution settings model when runtime override is missing", () => {
|
it("uses a complete runtime model only when no lane/task/default model is configured", () => {
|
||||||
expect(resolveHeartbeatSessionModels(
|
const runtimeConfig = { modelProvider: "anthropic", modelId: "claude-opus-4" };
|
||||||
{
|
|
||||||
executionProvider: "openai",
|
expect(resolveExecutorSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({
|
||||||
executionModelId: "gpt-4.1",
|
provider: "anthropic",
|
||||||
},
|
modelId: "claude-opus-4",
|
||||||
{},
|
});
|
||||||
)).toEqual({
|
expect(resolvePlanningSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({
|
||||||
defaultProvider: "openai",
|
provider: "anthropic",
|
||||||
defaultModelId: "gpt-4.1",
|
modelId: "claude-opus-4",
|
||||||
|
});
|
||||||
|
expect(resolveHeartbeatSessionModels(undefined, runtimeConfig)).toEqual({
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModelId: "claude-opus-4",
|
||||||
fallbackProvider: undefined,
|
fallbackProvider: undefined,
|
||||||
fallbackModelId: undefined,
|
fallbackModelId: undefined,
|
||||||
});
|
});
|
||||||
|
expect(resolveValidatorSessionModel(undefined, undefined, undefined, runtimeConfig)).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "claude-opus-4",
|
||||||
|
});
|
||||||
|
expect(resolveMergerSessionModel(undefined, runtimeConfig)).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "claude-opus-4",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not duplicate fallback when runtime and execution model are the same", () => {
|
it("covers backend-only surfaces; desktop and mobile breakpoints are not applicable", () => {
|
||||||
expect(resolveHeartbeatSessionModels(
|
expect(resolveExecutorSessionModel(undefined, undefined, settings, { model: "stale/old" })).toEqual({
|
||||||
{
|
provider: "openai",
|
||||||
executionProvider: "openai",
|
modelId: "gpt-4.1",
|
||||||
executionModelId: "gpt-4.1",
|
});
|
||||||
},
|
expect(resolvePlanningSessionModel(undefined, undefined, settings, { model: "stale/old" })).toEqual({
|
||||||
{ modelProvider: "openai", modelId: "gpt-4.1" },
|
provider: "anthropic",
|
||||||
)).toEqual({
|
modelId: "claude-sonnet-4-5",
|
||||||
defaultProvider: "openai",
|
});
|
||||||
defaultModelId: "gpt-4.1",
|
expect(resolveValidatorSessionModel(undefined, undefined, settings, { model: "stale/old" })).toEqual({
|
||||||
fallbackProvider: undefined,
|
provider: "google",
|
||||||
fallbackModelId: undefined,
|
modelId: "gemini-2.5-pro",
|
||||||
|
});
|
||||||
|
expect(resolveMergerSessionModel(settings, { model: "stale/old" })).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
modelId: "gemini-2.5-pro",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -220,15 +374,10 @@ describe("createResolvedAgentSession", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveMergerSessionModel", () => {
|
describe("resolveMergerSessionModel", () => {
|
||||||
it("uses assigned agent runtime model when both provider and modelId are present", () => {
|
it("uses assigned agent runtime model only when no default model pair is configured", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveMergerSessionModel(
|
resolveMergerSessionModel(
|
||||||
{
|
{},
|
||||||
defaultProviderOverride: "openai",
|
|
||||||
defaultModelIdOverride: "gpt-4.1",
|
|
||||||
defaultProvider: "anthropic",
|
|
||||||
defaultModelId: "claude-3-5-sonnet",
|
|
||||||
},
|
|
||||||
{ model: " anthropic/claude-3-5-sonnet-20241022 " },
|
{ model: " anthropic/claude-3-5-sonnet-20241022 " },
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
|||||||
@@ -3001,7 +3001,7 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(taskLogTool.name).toBe("fn_task_log");
|
expect(taskLogTool.name).toBe("fn_task_log");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes runtime model as primary and execution settings model as fallback", async () => {
|
it("passes execution settings model ahead of stale runtime model", async () => {
|
||||||
const store = createStoreWithAgentForExec({
|
const store = createStoreWithAgentForExec({
|
||||||
runtimeConfig: { model: "anthropic/claude-sonnet-4-5" },
|
runtimeConfig: { model: "anthropic/claude-sonnet-4-5" },
|
||||||
});
|
});
|
||||||
@@ -3022,10 +3022,10 @@ describe("executeHeartbeat", () => {
|
|||||||
|
|
||||||
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
||||||
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
||||||
expect(callArgs.defaultProvider).toBe("anthropic");
|
expect(callArgs.defaultProvider).toBe("openai");
|
||||||
expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5");
|
expect(callArgs.defaultModelId).toBe("gpt-4.1");
|
||||||
expect(callArgs.fallbackProvider).toBe("openai");
|
expect(callArgs.fallbackProvider).toBeUndefined();
|
||||||
expect(callArgs.fallbackModelId).toBe("gpt-4.1");
|
expect(callArgs.fallbackModelId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes undefined model when runtimeConfig has no model", async () => {
|
it("passes undefined model when runtimeConfig has no model", async () => {
|
||||||
|
|||||||
@@ -1145,7 +1145,7 @@ describe("MissionExecutionLoop", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses assigned agent runtime model ahead of task/settings for mission validation", async () => {
|
it("uses task/settings validator model ahead of assigned agent runtime model for mission validation", async () => {
|
||||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-MODEL-AGENT", status: "in-progress" });
|
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-MODEL-AGENT", status: "in-progress" });
|
||||||
missionStore._setFeature(feature);
|
missionStore._setFeature(feature);
|
||||||
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
|
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
|
||||||
@@ -1185,8 +1185,8 @@ describe("MissionExecutionLoop", () => {
|
|||||||
|
|
||||||
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
sessionPurpose: "validation",
|
sessionPurpose: "validation",
|
||||||
defaultProvider: "agent-provider",
|
defaultProvider: "task-validator",
|
||||||
defaultModelId: "agent-model",
|
defaultModelId: "task-validator-model",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3084,7 +3084,7 @@ describe("taskCreate tool model inheritance", () => {
|
|||||||
expect(capturedArgs.systemPrompt).toContain("agent ID: agent-007");
|
expect(capturedArgs.systemPrompt).toContain("agent ID: agent-007");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers assigned-agent runtime model and falls back when incomplete", async () => {
|
it("prefers planning settings model ahead of assigned-agent runtime model", async () => {
|
||||||
const completeRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-1", assignedAgentId: "agent-model-complete" });
|
const completeRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-1", assignedAgentId: "agent-model-complete" });
|
||||||
const incompleteRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-2", assignedAgentId: "agent-model-incomplete" });
|
const incompleteRuntimeTask = createTriageTask({ id: "FN-AGENT-MODEL-2", assignedAgentId: "agent-model-incomplete" });
|
||||||
|
|
||||||
@@ -3152,7 +3152,7 @@ describe("taskCreate tool model inheritance", () => {
|
|||||||
const completeCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-1");
|
const completeCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-1");
|
||||||
const fallbackCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-2");
|
const fallbackCall = capturedArgs.find((entry) => entry.taskId === "FN-AGENT-MODEL-2");
|
||||||
|
|
||||||
expect(completeCall).toMatchObject({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
|
expect(completeCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
|
||||||
expect(fallbackCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
|
expect(fallbackCall).toMatchObject({ defaultProvider: "openai", defaultModelId: "gpt-4o" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|||||||
import {
|
import {
|
||||||
isTestModeActive,
|
isTestModeActive,
|
||||||
resolveExecutionSettingsModel,
|
resolveExecutionSettingsModel,
|
||||||
|
resolveProjectDefaultModel,
|
||||||
resolveTaskExecutionModel,
|
resolveTaskExecutionModel,
|
||||||
resolveTaskPlanningModel,
|
resolveTaskPlanningModel,
|
||||||
|
resolveTaskValidatorModel,
|
||||||
TEST_MODE_RESOLVED,
|
TEST_MODE_RESOLVED,
|
||||||
|
type ResolvedModelSelection,
|
||||||
type Settings,
|
type Settings,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js";
|
import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js";
|
||||||
@@ -131,6 +134,32 @@ export function extractRuntimeModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCompleteRuntimeModel(
|
||||||
|
model: ResolvedModelSelection,
|
||||||
|
): model is { provider: string; modelId: string } {
|
||||||
|
return Boolean(model.provider && model.modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSettingsThenRuntimeModel(
|
||||||
|
settingsModel: ResolvedModelSelection,
|
||||||
|
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||||
|
): { provider: string | undefined; modelId: string | undefined } {
|
||||||
|
if (settingsModel.provider && settingsModel.modelId) {
|
||||||
|
return {
|
||||||
|
provider: settingsModel.provider,
|
||||||
|
modelId: settingsModel.modelId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
||||||
|
return hasCompleteRuntimeModel(assignedRuntimeModel)
|
||||||
|
? assignedRuntimeModel
|
||||||
|
: {
|
||||||
|
provider: settingsModel.provider,
|
||||||
|
modelId: settingsModel.modelId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveExecutorSessionModel(
|
export function resolveExecutorSessionModel(
|
||||||
taskModelProvider: string | undefined,
|
taskModelProvider: string | undefined,
|
||||||
taskModelId: string | undefined,
|
taskModelId: string | undefined,
|
||||||
@@ -144,11 +173,6 @@ export function resolveExecutorSessionModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
|
||||||
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
|
|
||||||
return assignedRuntimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTaskModel = resolveTaskExecutionModel(
|
const resolvedTaskModel = resolveTaskExecutionModel(
|
||||||
{
|
{
|
||||||
modelProvider: taskModelProvider,
|
modelProvider: taskModelProvider,
|
||||||
@@ -157,10 +181,7 @@ export function resolveExecutorSessionModel(
|
|||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return pickSettingsThenRuntimeModel(resolvedTaskModel, assignedAgentRuntimeConfig);
|
||||||
provider: resolvedTaskModel.provider,
|
|
||||||
modelId: resolvedTaskModel.modelId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePlanningSessionModel(
|
export function resolvePlanningSessionModel(
|
||||||
@@ -176,11 +197,6 @@ export function resolvePlanningSessionModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
|
||||||
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
|
|
||||||
return assignedRuntimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTaskPlanningModel = resolveTaskPlanningModel(
|
const resolvedTaskPlanningModel = resolveTaskPlanningModel(
|
||||||
{
|
{
|
||||||
planningModelProvider: taskPlanningModelProvider,
|
planningModelProvider: taskPlanningModelProvider,
|
||||||
@@ -189,10 +205,31 @@ export function resolvePlanningSessionModel(
|
|||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return pickSettingsThenRuntimeModel(resolvedTaskPlanningModel, assignedAgentRuntimeConfig);
|
||||||
provider: resolvedTaskPlanningModel.provider,
|
}
|
||||||
modelId: resolvedTaskPlanningModel.modelId,
|
|
||||||
};
|
export function resolveValidatorSessionModel(
|
||||||
|
taskValidatorModelProvider: string | undefined,
|
||||||
|
taskValidatorModelId: string | undefined,
|
||||||
|
settings: Partial<Settings> | undefined,
|
||||||
|
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||||
|
): { provider: string | undefined; modelId: string | undefined } {
|
||||||
|
if (isTestModeActive(settings)) {
|
||||||
|
return {
|
||||||
|
provider: TEST_MODE_RESOLVED.provider,
|
||||||
|
modelId: TEST_MODE_RESOLVED.modelId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTaskValidatorModel = resolveTaskValidatorModel(
|
||||||
|
{
|
||||||
|
validatorModelProvider: taskValidatorModelProvider,
|
||||||
|
validatorModelId: taskValidatorModelId,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return pickSettingsThenRuntimeModel(resolvedTaskValidatorModel, assignedAgentRuntimeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveHeartbeatSessionModels(
|
export function resolveHeartbeatSessionModels(
|
||||||
@@ -213,21 +250,14 @@ export function resolveHeartbeatSessionModels(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
|
||||||
const executionSettingsModel = resolveExecutionSettingsModel(settings);
|
const executionSettingsModel = resolveExecutionSettingsModel(settings);
|
||||||
|
const resolvedModel = pickSettingsThenRuntimeModel(executionSettingsModel, assignedAgentRuntimeConfig);
|
||||||
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 {
|
return {
|
||||||
defaultProvider,
|
defaultProvider: resolvedModel.provider,
|
||||||
defaultModelId,
|
defaultModelId: resolvedModel.modelId,
|
||||||
fallbackProvider: executionPairAvailable && !defaultMatchesExecution ? executionSettingsModel.provider : undefined,
|
fallbackProvider: undefined,
|
||||||
fallbackModelId: executionPairAvailable && !defaultMatchesExecution ? executionSettingsModel.modelId : undefined,
|
fallbackModelId: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,22 +272,11 @@ export function resolveMergerSessionModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
// Merger intentionally uses the default lane rather than execution/validator
|
||||||
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
|
// lanes. Validator-specific callers resolve `resolveValidatorSettingsModel`
|
||||||
return assignedRuntimeModel;
|
// before falling back here; generic merger work uses project/global defaults.
|
||||||
}
|
const defaultModel = resolveProjectDefaultModel(settings);
|
||||||
|
return pickSettingsThenRuntimeModel(defaultModel, assignedAgentRuntimeConfig);
|
||||||
if (settings?.defaultProviderOverride && settings.defaultModelIdOverride) {
|
|
||||||
return {
|
|
||||||
provider: settings.defaultProviderOverride,
|
|
||||||
modelId: settings.defaultModelIdOverride,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: settings?.defaultProvider,
|
|
||||||
modelId: settings?.defaultModelId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,17 +22,12 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
Milestone,
|
Milestone,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
|
||||||
TEST_MODE_RESOLVED,
|
|
||||||
isTestModeActive,
|
|
||||||
resolveTaskValidatorModel,
|
|
||||||
} from "@fusion/core";
|
|
||||||
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
|
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||||
import {
|
import {
|
||||||
createResolvedAgentSession,
|
createResolvedAgentSession,
|
||||||
extractRuntimeHint,
|
extractRuntimeHint,
|
||||||
extractRuntimeModel,
|
resolveValidatorSessionModel,
|
||||||
} from "./agent-session-helpers.js";
|
} from "./agent-session-helpers.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||||
@@ -573,30 +568,12 @@ export class MissionExecutionLoop extends EventEmitter {
|
|||||||
settings: Partial<Settings> | undefined,
|
settings: Partial<Settings> | undefined,
|
||||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||||
): { provider: string | undefined; modelId: string | undefined } {
|
): { provider: string | undefined; modelId: string | undefined } {
|
||||||
if (isTestModeActive(settings)) {
|
return resolveValidatorSessionModel(
|
||||||
return {
|
task?.validatorModelProvider,
|
||||||
provider: TEST_MODE_RESOLVED.provider,
|
task?.validatorModelId,
|
||||||
modelId: TEST_MODE_RESOLVED.modelId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
|
||||||
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
|
|
||||||
return assignedRuntimeModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedTaskModel = resolveTaskValidatorModel(
|
|
||||||
{
|
|
||||||
validatorModelProvider: task?.validatorModelProvider,
|
|
||||||
validatorModelId: task?.validatorModelId,
|
|
||||||
},
|
|
||||||
settings,
|
settings,
|
||||||
|
assignedAgentRuntimeConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
provider: resolvedTaskModel.provider,
|
|
||||||
modelId: resolvedTaskModel.modelId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1335,9 +1335,9 @@ export class TriageProcessor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Resolve planning model using executor-style precedence:
|
// Resolve planning model using executor-style precedence:
|
||||||
// 1. Assigned durable agent runtime model pair when complete
|
// 1. Task planning override pair
|
||||||
// 2. Task planning override pair
|
// 2. Planning/project/global fallbacks
|
||||||
// 3. Planning/project/global fallbacks
|
// 3. Assigned durable agent runtime model pair when no fresh model pair exists
|
||||||
const planningModel = resolvePlanningSessionModel(
|
const planningModel = resolvePlanningSessionModel(
|
||||||
task.planningModelProvider,
|
task.planningModelProvider,
|
||||||
task.planningModelId,
|
task.planningModelId,
|
||||||
|
|||||||
Reference in New Issue
Block a user