mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 17:01:53 +03:00
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
This commit is contained in:
+50
@@ -127,6 +127,18 @@ describe("trigger handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeCfg(home);
|
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 = {
|
||||||
...cfg.agents,
|
...cfg.agents,
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -150,6 +162,44 @@ describe("trigger handling", () => {
|
|||||||
expect(call?.model).toBe("claude-haiku-4-5-20251001");
|
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 () => {
|
it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export async function resolveReplyDirectives(params: {
|
|||||||
aliasIndex: ModelAliasIndex;
|
aliasIndex: ModelAliasIndex;
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
hasResolvedHeartbeatModelOverride: boolean;
|
||||||
typing: TypingController;
|
typing: TypingController;
|
||||||
opts?: GetReplyOptions;
|
opts?: GetReplyOptions;
|
||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
@@ -131,6 +132,7 @@ export async function resolveReplyDirectives(params: {
|
|||||||
defaultModel,
|
defaultModel,
|
||||||
provider: initialProvider,
|
provider: initialProvider,
|
||||||
model: initialModel,
|
model: initialModel,
|
||||||
|
hasResolvedHeartbeatModelOverride,
|
||||||
typing,
|
typing,
|
||||||
opts,
|
opts,
|
||||||
skillFilter,
|
skillFilter,
|
||||||
@@ -391,6 +393,7 @@ export async function resolveReplyDirectives(params: {
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
hasModelDirective: directives.hasModelDirective,
|
hasModelDirective: directives.hasModelDirective,
|
||||||
|
hasResolvedHeartbeatModelOverride,
|
||||||
});
|
});
|
||||||
provider = modelState.provider;
|
provider = modelState.provider;
|
||||||
model = modelState.model;
|
model = modelState.model;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export async function getReplyFromConfig(
|
|||||||
});
|
});
|
||||||
let provider = defaultProvider;
|
let provider = defaultProvider;
|
||||||
let model = defaultModel;
|
let model = defaultModel;
|
||||||
|
let hasResolvedHeartbeatModelOverride = false;
|
||||||
if (opts?.isHeartbeat) {
|
if (opts?.isHeartbeat) {
|
||||||
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
|
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
|
||||||
const heartbeatRef = heartbeatRaw
|
const heartbeatRef = heartbeatRaw
|
||||||
@@ -90,6 +91,7 @@ export async function getReplyFromConfig(
|
|||||||
if (heartbeatRef) {
|
if (heartbeatRef) {
|
||||||
provider = heartbeatRef.ref.provider;
|
provider = heartbeatRef.ref.provider;
|
||||||
model = heartbeatRef.ref.model;
|
model = heartbeatRef.ref.model;
|
||||||
|
hasResolvedHeartbeatModelOverride = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +198,7 @@ export async function getReplyFromConfig(
|
|||||||
aliasIndex,
|
aliasIndex,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
hasResolvedHeartbeatModelOverride,
|
||||||
typing,
|
typing,
|
||||||
opts: resolvedOpts,
|
opts: resolvedOpts,
|
||||||
skillFilter: mergedSkillFilter,
|
skillFilter: mergedSkillFilter,
|
||||||
|
|||||||
@@ -153,4 +153,62 @@ describe("createModelSelectionState parent inheritance", () => {
|
|||||||
expect(state.provider).toBe(defaultProvider);
|
expect(state.provider).toBe(defaultProvider);
|
||||||
expect(state.model).toBe(defaultModel);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -271,6 +271,9 @@ export async function createModelSelectionState(params: {
|
|||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
hasModelDirective: boolean;
|
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<ModelSelectionState> {
|
}): Promise<ModelSelectionState> {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
@@ -343,7 +346,11 @@ export async function createModelSelectionState(params: {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
parentSessionKey,
|
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 candidateProvider = storedOverride.provider || defaultProvider;
|
||||||
const key = modelKey(candidateProvider, storedOverride.model);
|
const key = modelKey(candidateProvider, storedOverride.model);
|
||||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user