From 6d723c9f8aa148fb049cdcd675bbe1faeb771715 Mon Sep 17 00:00:00 2001 From: 0xRain Date: Thu, 12 Feb 2026 01:46:51 +0800 Subject: [PATCH] fix(agents): honor heartbeat.model override instead of session model (#14181) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f19b789057e03d424ee20baf3c678475ad94f72f Co-authored-by: 0xRaini <190923101+0xRaini@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- ...or-cause-embedded-agent-throws.e2e.test.ts | 50 ++++++++++++++++ src/auto-reply/reply/get-reply-directives.ts | 3 + src/auto-reply/reply/get-reply.ts | 3 + .../model-selection.inherit-parent.test.ts | 58 +++++++++++++++++++ src/auto-reply/reply/model-selection.ts | 9 ++- 5 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts index cae7faf564..b96319d5be 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts @@ -127,6 +127,18 @@ describe("trigger handling", () => { }); const cfg = makeCfg(home); + await fs.writeFile( + join(home, "sessions.json"), + JSON.stringify({ + [_MAIN_SESSION_KEY]: { + sessionId: "main", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-5.2", + }, + }), + "utf-8", + ); cfg.agents = { ...cfg.agents, defaults: { @@ -150,6 +162,44 @@ describe("trigger handling", () => { expect(call?.model).toBe("claude-haiku-4-5-20251001"); }); }); + it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + await fs.writeFile( + join(home, "sessions.json"), + JSON.stringify({ + [_MAIN_SESSION_KEY]: { + sessionId: "main", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-5.2", + }, + }), + "utf-8", + ); + + await getReplyFromConfig( + { + Body: "hello", + From: "+1002", + To: "+2000", + }, + { isHeartbeat: true }, + makeCfg(home), + ); + + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.provider).toBe("openai"); + expect(call?.model).toBe("gpt-5.2"); + }); + }); it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index c9376e17f0..683011ae13 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -106,6 +106,7 @@ export async function resolveReplyDirectives(params: { aliasIndex: ModelAliasIndex; provider: string; model: string; + hasResolvedHeartbeatModelOverride: boolean; typing: TypingController; opts?: GetReplyOptions; skillFilter?: string[]; @@ -131,6 +132,7 @@ export async function resolveReplyDirectives(params: { defaultModel, provider: initialProvider, model: initialModel, + hasResolvedHeartbeatModelOverride, typing, opts, skillFilter, @@ -391,6 +393,7 @@ export async function resolveReplyDirectives(params: { provider, model, hasModelDirective: directives.hasModelDirective, + hasResolvedHeartbeatModelOverride, }); provider = modelState.provider; model = modelState.model; diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index af8c75cb68..4a449b1cb2 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -78,6 +78,7 @@ export async function getReplyFromConfig( }); let provider = defaultProvider; let model = defaultModel; + let hasResolvedHeartbeatModelOverride = false; if (opts?.isHeartbeat) { const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? ""; const heartbeatRef = heartbeatRaw @@ -90,6 +91,7 @@ export async function getReplyFromConfig( if (heartbeatRef) { provider = heartbeatRef.ref.provider; model = heartbeatRef.ref.model; + hasResolvedHeartbeatModelOverride = true; } } @@ -196,6 +198,7 @@ export async function getReplyFromConfig( aliasIndex, provider, model, + hasResolvedHeartbeatModelOverride, typing, opts: resolvedOpts, skillFilter: mergedSkillFilter, diff --git a/src/auto-reply/reply/model-selection.inherit-parent.test.ts b/src/auto-reply/reply/model-selection.inherit-parent.test.ts index f0d72e2353..e80088b42a 100644 --- a/src/auto-reply/reply/model-selection.inherit-parent.test.ts +++ b/src/auto-reply/reply/model-selection.inherit-parent.test.ts @@ -153,4 +153,62 @@ describe("createModelSelectionState parent inheritance", () => { expect(state.provider).toBe(defaultProvider); expect(state.model).toBe(defaultModel); }); + + it("applies stored override when heartbeat override was not resolved", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:discord:channel:c1"; + const sessionEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o", + }); + const sessionStore = { + [sessionKey]: sessionEntry, + }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: "anthropic", + model: "claude-opus-4-5", + hasModelDirective: false, + hasResolvedHeartbeatModelOverride: false, + }); + + expect(state.provider).toBe("openai"); + expect(state.model).toBe("gpt-4o"); + }); + + it("skips stored override when heartbeat override was resolved", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:discord:channel:c1"; + const sessionEntry = makeEntry({ + providerOverride: "openai", + modelOverride: "gpt-4o", + }); + const sessionStore = { + [sessionKey]: sessionEntry, + }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: "anthropic", + model: "claude-opus-4-5", + hasModelDirective: false, + hasResolvedHeartbeatModelOverride: true, + }); + + expect(state.provider).toBe("anthropic"); + expect(state.model).toBe("claude-opus-4-5"); + }); }); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index fa5fa36abb..b77b5251f9 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -271,6 +271,9 @@ export async function createModelSelectionState(params: { provider: string; model: string; hasModelDirective: boolean; + /** True when heartbeat.model was explicitly resolved for this run. + * In that case, skip session-stored overrides so the heartbeat selection wins. */ + hasResolvedHeartbeatModelOverride?: boolean; }): Promise { const { cfg, @@ -343,7 +346,11 @@ export async function createModelSelectionState(params: { sessionKey, parentSessionKey, }); - if (storedOverride?.model) { + // Skip stored session model override only when an explicit heartbeat.model + // was resolved. Heartbeat runs without heartbeat.model should still inherit + // the regular session/parent model override behavior. + const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true; + if (storedOverride?.model && !skipStoredOverride) { const candidateProvider = storedOverride.provider || defaultProvider; const key = modelKey(candidateProvider, storedOverride.model); if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {