diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 4a449b1cb2..d2b4702993 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -80,7 +80,10 @@ export async function getReplyFromConfig( let model = defaultModel; let hasResolvedHeartbeatModelOverride = false; if (opts?.isHeartbeat) { - const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? ""; + // Prefer the resolved per-agent heartbeat model passed from the heartbeat runner, + // fall back to the global defaults heartbeat model for backward compatibility. + const heartbeatRaw = + opts.heartbeatModelOverride?.trim() ?? agentCfg?.heartbeat?.model?.trim() ?? ""; const heartbeatRef = heartbeatRaw ? resolveModelRefFromString({ raw: heartbeatRaw, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 406bd8d033..6993af45b8 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -27,6 +27,8 @@ export type GetReplyOptions = { onTypingCleanup?: () => void; onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; + /** Resolved heartbeat model override (provider/model string from merged per-agent config). */ + heartbeatModelOverride?: string; onPartialReply?: (payload: ReplyPayload) => Promise | void; onReasoningStream?: (payload: ReplyPayload) => Promise | void; onBlockReply?: (payload: ReplyPayload, context?: BlockReplyContext) => Promise | void; diff --git a/src/infra/heartbeat-active-hours.test.ts b/src/infra/heartbeat-active-hours.test.ts new file mode 100644 index 0000000000..e3bce7f5bd --- /dev/null +++ b/src/infra/heartbeat-active-hours.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { isWithinActiveHours } from "./heartbeat-active-hours.js"; + +function cfgWithUserTimezone(userTimezone = "UTC"): OpenClawConfig { + return { + agents: { + defaults: { + userTimezone, + }, + }, + }; +} + +function heartbeatWindow(start: string, end: string, timezone: string) { + return { + activeHours: { + start, + end, + timezone, + }, + }; +} + +describe("isWithinActiveHours", () => { + it("returns true when activeHours is not configured", () => { + expect( + isWithinActiveHours(cfgWithUserTimezone("UTC"), undefined, Date.UTC(2025, 0, 1, 3)), + ).toBe(true); + }); + + it("returns true when activeHours start/end are invalid", () => { + const cfg = cfgWithUserTimezone("UTC"); + expect( + isWithinActiveHours(cfg, heartbeatWindow("bad", "10:00", "UTC"), Date.UTC(2025, 0, 1, 9)), + ).toBe(true); + expect( + isWithinActiveHours(cfg, heartbeatWindow("08:00", "24:30", "UTC"), Date.UTC(2025, 0, 1, 9)), + ).toBe(true); + }); + + it("returns true when activeHours start equals end", () => { + const cfg = cfgWithUserTimezone("UTC"); + expect( + isWithinActiveHours( + cfg, + heartbeatWindow("08:00", "08:00", "UTC"), + Date.UTC(2025, 0, 1, 12, 0, 0), + ), + ).toBe(true); + }); + + it("respects user timezone windows for normal ranges", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("08:00", "24:00", "user"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 7, 0, 0))).toBe(false); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 8, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 23, 59, 0))).toBe(true); + }); + + it("supports overnight ranges", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("22:00", "06:00", "UTC"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 23, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 5, 30, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 12, 0, 0))).toBe(false); + }); + + it("respects explicit non-user timezones", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("09:00", "17:00", "America/New_York"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 15, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 23, 30, 0))).toBe(false); + }); + + it("falls back to user timezone when activeHours timezone is invalid", () => { + const cfg = cfgWithUserTimezone("UTC"); + const heartbeat = heartbeatWindow("08:00", "10:00", "Mars/Olympus"); + + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 9, 0, 0))).toBe(true); + expect(isWithinActiveHours(cfg, heartbeat, Date.UTC(2025, 0, 1, 11, 0, 0))).toBe(false); + }); +}); diff --git a/src/infra/heartbeat-active-hours.ts b/src/infra/heartbeat-active-hours.ts new file mode 100644 index 0000000000..b8f18efbba --- /dev/null +++ b/src/infra/heartbeat-active-hours.ts @@ -0,0 +1,99 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; +import { resolveUserTimezone } from "../agents/date-time.js"; + +type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; + +const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/; + +function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "user") { + return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); + } + if (trimmed === "local") { + const host = Intl.DateTimeFormat().resolvedOptions().timeZone; + return host?.trim() || "UTC"; + } + try { + new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date()); + return trimmed; + } catch { + return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); + } +} + +function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null { + if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) { + return null; + } + const [hourStr, minuteStr] = raw.split(":"); + const hour = Number(hourStr); + const minute = Number(minuteStr); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) { + return null; + } + if (hour === 24) { + if (!opts.allow24 || minute !== 0) { + return null; + } + return 24 * 60; + } + return hour * 60 + minute; +} + +function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(nowMs)); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") { + map[part.type] = part.value; + } + } + const hour = Number(map.hour); + const minute = Number(map.minute); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) { + return null; + } + return hour * 60 + minute; + } catch { + return null; + } +} + +export function isWithinActiveHours( + cfg: OpenClawConfig, + heartbeat?: HeartbeatConfig, + nowMs?: number, +): boolean { + const active = heartbeat?.activeHours; + if (!active) { + return true; + } + + const startMin = parseActiveHoursTime({ allow24: false }, active.start); + const endMin = parseActiveHoursTime({ allow24: true }, active.end); + if (startMin === null || endMin === null) { + return true; + } + if (startMin === endMin) { + return true; + } + + const timeZone = resolveActiveHoursTimezone(cfg, active.timezone); + const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone); + if (currentMin === null) { + return true; + } + + if (endMin > startMin) { + return currentMin >= startMin && currentMin < endMin; + } + return currentMin >= startMin || currentMin < endMin; +} diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts new file mode 100644 index 0000000000..c3e393fd7d --- /dev/null +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -0,0 +1,246 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; +import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; +import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js"; +import * as replyModule from "../auto-reply/reply.js"; +import { resolveAgentMainSessionKey, resolveMainSessionKey } from "../config/sessions.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createPluginRuntime } from "../plugins/runtime/index.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { runHeartbeatOnce } from "./heartbeat-runner.js"; + +// Avoid pulling optional runtime deps during isolated runs. +vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); + +type SeedSessionInput = { + lastChannel: string; + lastTo: string; + updatedAt?: number; +}; + +async function withHeartbeatFixture( + run: (ctx: { + tmpDir: string; + storePath: string; + seedSession: (sessionKey: string, input: SeedSessionInput) => Promise; + }) => Promise, +) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-model-")); + const storePath = path.join(tmpDir, "sessions.json"); + + const seedSession = async (sessionKey: string, input: SeedSessionInput) => { + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: input.updatedAt ?? Date.now(), + lastChannel: input.lastChannel, + lastTo: input.lastTo, + }, + }, + null, + 2, + ), + ); + }; + + try { + await run({ tmpDir, storePath, seedSession }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +beforeEach(() => { + const runtime = createPluginRuntime(); + setTelegramRuntime(runtime); + setWhatsAppRuntime(runtime); + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, + { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, + ]), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("runHeartbeatOnce – heartbeat model override", () => { + it("passes heartbeatModelOverride from defaults heartbeat config", async () => { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "whatsapp", + model: "ollama/llama3.2:1b", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + + await runHeartbeatOnce({ + cfg, + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(replySpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + isHeartbeat: true, + heartbeatModelOverride: "ollama/llama3.2:1b", + }), + cfg, + ); + }); + }); + + it("passes per-agent heartbeat model override (merged with defaults)", async () => { + await withHeartbeatFixture(async ({ storePath, seedSession }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { + every: "30m", + model: "openai/gpt-4o-mini", + }, + }, + list: [ + { id: "main", default: true }, + { + id: "ops", + heartbeat: { + every: "5m", + target: "whatsapp", + model: "ollama/llama3.2:1b", + }, + }, + ], + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" }); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + + await runHeartbeatOnce({ + cfg, + agentId: "ops", + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(replySpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + isHeartbeat: true, + heartbeatModelOverride: "ollama/llama3.2:1b", + }), + cfg, + ); + }); + }); + + it("does not pass heartbeatModelOverride when no heartbeat model is configured", async () => { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "whatsapp", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + + await runHeartbeatOnce({ + cfg, + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const replyOpts = replySpy.mock.calls[0]?.[1]; + expect(replyOpts).toStrictEqual({ isHeartbeat: true }); + expect(replyOpts).not.toHaveProperty("heartbeatModelOverride"); + }); + }); + + it("trims heartbeat model override before passing it downstream", async () => { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "whatsapp", + model: " ollama/llama3.2:1b ", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" }); + + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + + await runHeartbeatOnce({ + cfg, + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(replySpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + isHeartbeat: true, + heartbeatModelOverride: "ollama/llama3.2:1b", + }), + cfg, + ); + }); + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 33414dc38c..a51a8ec563 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -11,7 +11,6 @@ import { resolveDefaultAgentId, } from "../agents/agent-scope.js"; import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; -import { resolveUserTimezone } from "../agents/date-time.js"; import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import { @@ -41,6 +40,7 @@ import { CommandLane } from "../process/lanes.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { formatErrorMessage } from "./errors.js"; +import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { @@ -87,7 +87,6 @@ export type HeartbeatSummary = { }; const DEFAULT_HEARTBEAT_TARGET = "last"; -const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/; // Prompt used when an async exec has completed and the result should be relayed to the user. // This overrides the standard heartbeat prompt to ensure the model responds with the exec result @@ -104,98 +103,6 @@ const CRON_EVENT_PROMPT = "A scheduled reminder has been triggered. The reminder message is shown in the system messages above. " + "Please relay this reminder to the user in a helpful and friendly way."; -function resolveActiveHoursTimezone(cfg: OpenClawConfig, raw?: string): string { - const trimmed = raw?.trim(); - if (!trimmed || trimmed === "user") { - return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); - } - if (trimmed === "local") { - const host = Intl.DateTimeFormat().resolvedOptions().timeZone; - return host?.trim() || "UTC"; - } - try { - new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date()); - return trimmed; - } catch { - return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); - } -} - -function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null { - if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) { - return null; - } - const [hourStr, minuteStr] = raw.split(":"); - const hour = Number(hourStr); - const minute = Number(minuteStr); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) { - return null; - } - if (hour === 24) { - if (!opts.allow24 || minute !== 0) { - return null; - } - return 24 * 60; - } - return hour * 60 + minute; -} - -function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(new Date(nowMs)); - const map: Record = {}; - for (const part of parts) { - if (part.type !== "literal") { - map[part.type] = part.value; - } - } - const hour = Number(map.hour); - const minute = Number(map.minute); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) { - return null; - } - return hour * 60 + minute; - } catch { - return null; - } -} - -function isWithinActiveHours( - cfg: OpenClawConfig, - heartbeat?: HeartbeatConfig, - nowMs?: number, -): boolean { - const active = heartbeat?.activeHours; - if (!active) { - return true; - } - - const startMin = parseActiveHoursTime({ allow24: false }, active.start); - const endMin = parseActiveHoursTime({ allow24: true }, active.end); - if (startMin === null || endMin === null) { - return true; - } - if (startMin === endMin) { - return true; - } - - const timeZone = resolveActiveHoursTimezone(cfg, active.timezone); - const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone); - if (currentMin === null) { - return true; - } - - if (endMin > startMin) { - return currentMin >= startMin && currentMin < endMin; - } - return currentMin >= startMin || currentMin < endMin; -} - type HeartbeatAgentState = { agentId: string; heartbeat?: HeartbeatConfig; @@ -637,7 +544,11 @@ export async function runHeartbeatOnce(opts: { }; try { - const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg); + const heartbeatModelOverride = heartbeat?.model?.trim() || undefined; + const replyOpts = heartbeatModelOverride + ? { isHeartbeat: true, heartbeatModelOverride } + : { isHeartbeat: true }; + const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg); const replyPayload = resolveHeartbeatReplyPayload(replyResult); const includeReasoning = heartbeat?.includeReasoning === true; const reasoningPayloads = includeReasoning