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
This commit is contained in:
Tak Hoffman
2026-02-10 18:58:45 -06:00
committed by GitHub
parent a853ded782
commit d2c2f4185b
6 changed files with 98 additions and 17 deletions

View File

@@ -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}`;
}

View File

@@ -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

View File

@@ -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,
);

View File

@@ -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",

View File

@@ -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 });
}
});
});

View File

@@ -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,