feat: add per-agent heartbeat config

This commit is contained in:
Peter Steinberger
2026-01-16 00:46:07 +00:00
parent f8f319713f
commit 61e385b331
14 changed files with 441 additions and 185 deletions

View File

@@ -22,6 +22,7 @@ type ResolvedAgentConfig = {
model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
heartbeat?: AgentEntry["heartbeat"];
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
@@ -89,6 +90,7 @@ export function resolveAgentConfig(
: undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
heartbeat: entry.heartbeat,
identity: entry.identity,
groupChat: entry.groupChat,
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,

View File

@@ -27,6 +27,8 @@ export type AgentConfig = {
memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig;
/** Optional per-agent heartbeat overrides. */
heartbeat?: AgentDefaultsConfig["heartbeat"];
identity?: IdentityConfig;
groupChat?: GroupChatConfig;
subagents?: {

View File

@@ -255,6 +255,7 @@ export const AgentEntrySchema = z.object({
model: AgentModelSchema.optional(),
memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(),
heartbeat: HeartbeatSchema,
identity: IdentitySchema,
groupChat: GroupChatSchema,
subagents: z

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import * as replyModule from "../auto-reply/reply.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
// Avoid pulling optional runtime deps during isolated runs.
@@ -15,22 +16,6 @@ describe("resolveHeartbeatIntervalMs", () => {
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -45,6 +30,23 @@ describe("resolveHeartbeatIntervalMs", () => {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
const sendWhatsApp = vi.fn().mockResolvedValue({
@@ -75,22 +77,6 @@ describe("resolveHeartbeatIntervalMs", () => {
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -104,6 +90,23 @@ describe("resolveHeartbeatIntervalMs", () => {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
const sendWhatsApp = vi.fn().mockResolvedValue({
@@ -136,22 +139,6 @@ describe("resolveHeartbeatIntervalMs", () => {
try {
const originalUpdatedAt = 1000;
const bumpedUpdatedAt = 2000;
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sid",
updatedAt: originalUpdatedAt,
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -165,12 +152,32 @@ describe("resolveHeartbeatIntervalMs", () => {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: originalUpdatedAt,
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockImplementationOnce(async () => {
const raw = await fs.readFile(storePath, "utf-8");
const parsed = JSON.parse(raw) as { main?: { updatedAt?: number } };
if (parsed.main) {
parsed.main.updatedAt = bumpedUpdatedAt;
const parsed = JSON.parse(raw) as Record<string, { updatedAt?: number } | undefined>;
if (parsed[sessionKey]) {
parsed[sessionKey] = {
...parsed[sessionKey],
updatedAt: bumpedUpdatedAt,
};
}
await fs.writeFile(storePath, JSON.stringify(parsed, null, 2));
return { text: "" };
@@ -186,10 +193,11 @@ describe("resolveHeartbeatIntervalMs", () => {
},
});
const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
main?: { updatedAt?: number };
};
expect(finalStore.main?.updatedAt).toBe(bumpedUpdatedAt);
const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ updatedAt?: number } | undefined
>;
expect(finalStore[sessionKey]?.updatedAt).toBe(bumpedUpdatedAt);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
@@ -201,11 +209,22 @@ describe("resolveHeartbeatIntervalMs", () => {
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
@@ -217,16 +236,6 @@ describe("resolveHeartbeatIntervalMs", () => {
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
@@ -260,11 +269,22 @@ describe("resolveHeartbeatIntervalMs", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "telegram", to: "123456" },
},
},
channels: { telegram: { botToken: "test-bot-token-123" } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "telegram",
@@ -276,16 +296,6 @@ describe("resolveHeartbeatIntervalMs", () => {
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "telegram", to: "123456" },
},
},
channels: { telegram: { botToken: "test-bot-token-123" } },
session: { store: storePath },
};
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
@@ -325,22 +335,6 @@ describe("resolveHeartbeatIntervalMs", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "telegram",
lastTo: "123456",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -356,6 +350,23 @@ describe("resolveHeartbeatIntervalMs", () => {
},
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "telegram",
lastTo: "123456",
},
},
null,
2,
),
);
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
const sendTelegram = vi.fn().mockResolvedValue({

View File

@@ -7,6 +7,7 @@ import * as replyModule from "../auto-reply/reply.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
@@ -55,6 +56,16 @@ describe("resolveHeartbeatIntervalMs", () => {
}),
).toBe(2 * 60 * 60_000);
});
it("uses explicit heartbeat overrides when provided", () => {
expect(
resolveHeartbeatIntervalMs(
{ agents: { defaults: { heartbeat: { every: "30m" } } } },
undefined,
{ every: "5m" },
),
).toBe(5 * 60_000);
});
});
describe("resolveHeartbeatPrompt", () => {
@@ -183,6 +194,23 @@ describe("resolveHeartbeatDeliveryTarget", () => {
to: "123",
});
});
it("prefers per-agent heartbeat overrides when provided", () => {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
};
const heartbeat = { target: "whatsapp", to: "+1555" } as const;
expect(
resolveHeartbeatDeliveryTarget({
cfg,
entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1999" },
heartbeat,
}),
).toEqual({
channel: "whatsapp",
to: "+1555",
});
});
});
describe("runHeartbeatOnce", () => {
@@ -191,11 +219,22 @@ describe("runHeartbeatOnce", () => {
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
@@ -207,16 +246,6 @@ describe("runHeartbeatOnce", () => {
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
@@ -242,6 +271,76 @@ describe("runHeartbeatOnce", () => {
}
});
it("uses per-agent heartbeat overrides and session keys", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "30m", prompt: "Default prompt" },
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" },
},
],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" });
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockResolvedValue([{ text: "Final alert" }]);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({
cfg,
agentId: "ops",
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
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 }),
{ isHeartbeat: true },
cfg,
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("suppresses duplicate heartbeat payloads within 24h", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
@@ -302,22 +401,6 @@ describe("runHeartbeatOnce", () => {
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -332,6 +415,23 @@ describe("runHeartbeatOnce", () => {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockResolvedValue([
{ text: "Reasoning:\n_Because it helps_" },
@@ -372,22 +472,6 @@ describe("runHeartbeatOnce", () => {
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.writeFile(
storePath,
JSON.stringify(
{
main: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@@ -402,6 +486,23 @@ describe("runHeartbeatOnce", () => {
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
replySpy.mockResolvedValue([
{ text: "Reasoning:\n_Because it helps_" },

View File

@@ -1,3 +1,4 @@
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@@ -14,20 +15,22 @@ import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveMainSessionKey,
resolveAgentMainSessionKey,
resolveStorePath,
saveSessionStore,
updateSessionStore,
} from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging.js";
import { getQueueSize } from "../process/command-queue.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js";
import {
type HeartbeatRunResult,
type HeartbeatWakeHandler,
requestHeartbeatNow,
setHeartbeatWakeHandler,
} from "./heartbeat-wake.js";
@@ -49,8 +52,48 @@ export function setHeartbeatsEnabled(enabled: boolean) {
heartbeatsEnabled = enabled;
}
export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?: string) {
const raw = overrideEvery ?? cfg.agents?.defaults?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY;
type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
type HeartbeatAgent = {
agentId: string;
heartbeat?: HeartbeatConfig;
};
function resolveHeartbeatConfig(
cfg: ClawdbotConfig,
agentId?: string,
): HeartbeatConfig | undefined {
const defaults = cfg.agents?.defaults?.heartbeat;
if (!agentId) return defaults;
const overrides = resolveAgentConfig(cfg, agentId)?.heartbeat;
if (!defaults && !overrides) return overrides;
return { ...defaults, ...overrides };
}
function resolveHeartbeatAgents(cfg: ClawdbotConfig): HeartbeatAgent[] {
const list = cfg.agents?.list ?? [];
const explicit = list.filter((entry) => entry?.heartbeat);
if (explicit.length > 0) {
return explicit
.map((entry) => {
const id = normalizeAgentId(entry.id);
return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) };
})
.filter((entry) => entry.agentId);
}
const fallbackId = resolveDefaultAgentId(cfg);
return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }];
}
export function resolveHeartbeatIntervalMs(
cfg: ClawdbotConfig,
overrideEvery?: string,
heartbeat?: HeartbeatConfig,
) {
const raw =
overrideEvery ??
heartbeat?.every ??
cfg.agents?.defaults?.heartbeat?.every ??
DEFAULT_HEARTBEAT_EVERY;
if (!raw) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
@@ -64,23 +107,31 @@ export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?:
return ms;
}
export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) {
return resolveHeartbeatPromptText(cfg.agents?.defaults?.heartbeat?.prompt);
}
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
return Math.max(
0,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
export function resolveHeartbeatPrompt(cfg: ClawdbotConfig, heartbeat?: HeartbeatConfig) {
return resolveHeartbeatPromptText(
heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt,
);
}
function resolveHeartbeatSession(cfg: ClawdbotConfig) {
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatConfig) {
return Math.max(
0,
heartbeat?.ackMaxChars ??
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
}
function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) {
const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const sessionKey = scope === "global" ? "global" : resolveMainSessionKey(cfg);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
const sessionKey =
scope === "global"
? "global"
: resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
return { sessionKey, storePath, store, entry };
@@ -186,14 +237,18 @@ function normalizeHeartbeatReply(
export async function runHeartbeatOnce(opts: {
cfg?: ClawdbotConfig;
agentId?: string;
heartbeat?: HeartbeatConfig;
reason?: string;
deps?: HeartbeatDeps;
}): Promise<HeartbeatRunResult> {
const cfg = opts.cfg ?? loadConfig();
const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg));
const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId);
if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" };
}
if (!resolveHeartbeatIntervalMs(cfg)) {
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
return { status: "skipped", reason: "disabled" };
}
@@ -203,9 +258,9 @@ export async function runHeartbeatOnce(opts: {
}
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId);
const previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry });
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
const lastChannel =
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
? normalizeChannelId(entry.lastChannel)
@@ -222,18 +277,19 @@ export async function runHeartbeatOnce(opts: {
lastTo: entry?.lastTo,
provider: senderProvider,
});
const prompt = resolveHeartbeatPrompt(cfg);
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: prompt,
From: sender,
To: sender,
Provider: "heartbeat",
SessionKey: sessionKey,
};
try {
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
const includeReasoning = heartbeat?.includeReasoning === true;
const reasoningPayloads = includeReasoning
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
: [];
@@ -255,10 +311,10 @@ export async function runHeartbeatOnce(opts: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const normalized = normalizeHeartbeatReply(
replyPayload,
resolveEffectiveMessagesConfig(cfg, resolveAgentIdFromSessionKey(sessionKey)).responsePrefix,
resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
ackMaxChars,
);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
@@ -409,19 +465,57 @@ export function startHeartbeatRunner(opts: {
abortSignal?: AbortSignal;
}) {
const cfg = opts.cfg ?? loadConfig();
const intervalMs = resolveHeartbeatIntervalMs(cfg);
const heartbeatAgents = resolveHeartbeatAgents(cfg);
const intervals = heartbeatAgents
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
.filter((value): value is number => typeof value === "number");
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
if (!intervalMs) {
log.info("heartbeat: disabled", { enabled: false });
}
const runtime = opts.runtime ?? defaultRuntime;
const run = async (params?: { reason?: string }) => {
const res = await runHeartbeatOnce({
cfg,
reason: params?.reason,
deps: { runtime },
});
return res;
const lastRunByAgent = new Map<string, number>();
const run: HeartbeatWakeHandler = async (params) => {
if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
}
if (heartbeatAgents.length === 0) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
}
const reason = params?.reason;
const isInterval = reason === "interval";
const startedAt = Date.now();
const now = startedAt;
let ran = false;
for (const agent of heartbeatAgents) {
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
if (!agentIntervalMs) continue;
const lastRun = lastRunByAgent.get(agent.agentId);
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
continue;
}
const res = await runHeartbeatOnce({
cfg,
agentId: agent.agentId,
heartbeat: agent.heartbeat,
reason,
deps: { runtime },
});
if (res.status === "skipped" && res.reason === "requests-in-flight") {
return res;
}
if (res.status !== "skipped" || res.reason !== "disabled") {
lastRunByAgent.set(agent.agentId, now);
}
if (res.status === "ran") ran = true;
}
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
};
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));

View File

@@ -2,6 +2,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind
import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
import type {
DeliverableMessageChannel,
GatewayMessageChannel,
@@ -79,9 +80,11 @@ export function resolveOutboundTarget(params: {
export function resolveHeartbeatDeliveryTarget(params: {
cfg: ClawdbotConfig;
entry?: SessionEntry;
heartbeat?: AgentDefaultsConfig["heartbeat"];
}): OutboundTarget {
const { cfg, entry } = params;
const rawTarget = cfg.agents?.defaults?.heartbeat?.target;
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
const rawTarget = heartbeat?.target;
let target: HeartbeatTarget = "last";
if (rawTarget === "none" || rawTarget === "last") {
target = rawTarget;
@@ -95,10 +98,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
const explicitTo =
typeof cfg.agents?.defaults?.heartbeat?.to === "string" &&
cfg.agents.defaults.heartbeat.to.trim()
? cfg.agents.defaults.heartbeat.to.trim()
: undefined;
typeof heartbeat?.to === "string" && heartbeat.to.trim() ? heartbeat.to.trim() : undefined;
const lastChannel =
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL