From d2c2f4185b0e2f7a13c11e1bc79aeb5cc37b1f9a Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:58:45 -0600 Subject: [PATCH] Heartbeat: inject cron-style current time into prompts (#13733) * Heartbeat: inject cron-style current time into prompts * Tests: fix type for web heartbeat timestamp test * Infra: inline heartbeat current-time injection --- src/agents/current-time.ts | 39 ++++++++++++++++ src/cron/isolated-agent/run.ts | 12 +---- ...tbeat-runner.returns-default-unset.test.ts | 8 ++-- src/infra/heartbeat-runner.ts | 4 +- .../heartbeat-runner.timestamp.test.ts | 45 +++++++++++++++++++ src/web/auto-reply/heartbeat-runner.ts | 7 ++- 6 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 src/agents/current-time.ts create mode 100644 src/web/auto-reply/heartbeat-runner.timestamp.test.ts diff --git a/src/agents/current-time.ts b/src/agents/current-time.ts new file mode 100644 index 00000000000..b1f13512e71 --- /dev/null +++ b/src/agents/current-time.ts @@ -0,0 +1,39 @@ +import { + type TimeFormatPreference, + formatUserTime, + resolveUserTimeFormat, + resolveUserTimezone, +} from "./date-time.js"; + +export type CronStyleNow = { + userTimezone: string; + formattedTime: string; + timeLine: string; +}; + +type TimeConfigLike = { + agents?: { + defaults?: { + userTimezone?: string; + timeFormat?: TimeFormatPreference; + }; + }; +}; + +export function resolveCronStyleNow(cfg: TimeConfigLike, nowMs: number): CronStyleNow { + const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone); + const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat); + const formattedTime = + formatUserTime(new Date(nowMs), userTimezone, userTimeFormat) ?? new Date(nowMs).toISOString(); + const timeLine = `Current time: ${formattedTime} (${userTimezone})`; + return { userTimezone, formattedTime, timeLine }; +} + +export function appendCronStyleCurrentTimeLine(text: string, cfg: TimeConfigLike, nowMs: number) { + const base = text.trimEnd(); + if (!base || base.includes("Current time:")) { + return base; + } + const { timeLine } = resolveCronStyleNow(cfg, nowMs); + return `${base}\n${timeLine}`; +} diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 0d1993b4fb5..29d3e629f7c 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -12,11 +12,7 @@ import { import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; -import { - formatUserTime, - resolveUserTimeFormat, - resolveUserTimezone, -} from "../../agents/date-time.js"; +import { resolveCronStyleNow } from "../../agents/current-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; @@ -288,11 +284,7 @@ export async function runCronIsolatedAgentTurn(params: { to: deliveryPlan.to, }); - const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone); - const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat); - const formattedTime = - formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString(); - const timeLine = `Current time: ${formattedTime} (${userTimezone})`; + const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now); const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); // SECURITY: Wrap external hook content with security boundaries to prevent prompt injection diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 18729628d26..25cde979c75 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -493,13 +493,11 @@ describe("runHeartbeatOnce", () => { 2, ), ); - replySpy.mockResolvedValue([{ text: "Final alert" }]); const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid", }); - await runHeartbeatOnce({ cfg, agentId: "ops", @@ -511,11 +509,13 @@ describe("runHeartbeatOnce", () => { hasActiveWebListener: () => true, }, }); - expect(sendWhatsApp).toHaveBeenCalledTimes(1); expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); expect(replySpy).toHaveBeenCalledWith( - expect.objectContaining({ Body: "Ops check", SessionKey: sessionKey }), + expect.objectContaining({ + Body: expect.stringMatching(/Ops check[\s\S]*Current time: /), + SessionKey: sessionKey, + }), { isHeartbeat: true }, cfg, ); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 09c8ddd5910..33414dc38cb 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -10,6 +10,7 @@ import { resolveAgentWorkspaceDir, 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"; @@ -582,14 +583,13 @@ export async function runHeartbeatOnce(opts: { const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : []; const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished")); const hasCronEvents = isCronEvent && pendingEvents.length > 0; - const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : hasCronEvents ? CRON_EVENT_PROMPT : resolveHeartbeatPrompt(cfg, heartbeat); const ctx = { - Body: prompt, + Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), From: sender, To: sender, Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat", diff --git a/src/web/auto-reply/heartbeat-runner.timestamp.test.ts b/src/web/auto-reply/heartbeat-runner.timestamp.test.ts new file mode 100644 index 00000000000..e83aacdb26f --- /dev/null +++ b/src/web/auto-reply/heartbeat-runner.timestamp.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { runWebHeartbeatOnce } from "./heartbeat-runner.js"; + +describe("runWebHeartbeatOnce (timestamp)", () => { + it("injects a cron-style Current time line into the heartbeat prompt", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + try { + await fs.writeFile(storePath, JSON.stringify({}, null, 2)); + + const replyResolver = vi.fn().mockResolvedValue([{ text: "HEARTBEAT_OK" }]); + const cfg = { + agents: { + defaults: { + heartbeat: { prompt: "Ops check", every: "5m" }, + userTimezone: "America/Chicago", + timeFormat: "24", + }, + }, + session: { store: storePath }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as unknown as OpenClawConfig; + + await runWebHeartbeatOnce({ + cfg, + to: "+1555", + dryRun: true, + replyResolver, + sender: vi.fn(), + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + const ctx = replyResolver.mock.calls[0]?.[0]; + expect(ctx?.Body).toMatch(/Ops check/); + expect(ctx?.Body).toMatch(/Current time: /); + expect(ctx?.Body).toMatch(/\(.+\)/); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/web/auto-reply/heartbeat-runner.ts b/src/web/auto-reply/heartbeat-runner.ts index 968e904fc81..3906690eee9 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/src/web/auto-reply/heartbeat-runner.ts @@ -1,4 +1,5 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; +import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, resolveHeartbeatPrompt, @@ -159,7 +160,11 @@ export async function runWebHeartbeatOnce(opts: { const replyResult = await replyResolver( { - Body: resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt), + Body: appendCronStyleCurrentTimeLine( + resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt), + cfg, + Date.now(), + ), From: to, To: to, MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId,