From a42e3cb78ab75233a5ebcc949d2532630ec234c5 Mon Sep 17 00:00:00 2001 From: lsh411 Date: Thu, 5 Feb 2026 06:49:12 +0900 Subject: [PATCH] feat(heartbeat): add accountId config option for multi-agent routing (#8702) * feat(heartbeat): add accountId config option for multi-agent routing Add optional accountId field to heartbeat configuration, allowing multi-agent setups to explicitly specify which Telegram account should be used for heartbeat delivery. Previously, heartbeat delivery would use the accountId from the session's deliveryContext. When a session had no prior conversation history, heartbeats would default to the first/primary account instead of the agent's intended bot. Changes: - Add accountId to HeartbeatSchema (zod-schema.agent-runtime.ts) - Use heartbeat.accountId with fallback to session accountId (targets.ts) Backward compatible: if accountId is not specified, behavior is unchanged. Closes #8695 * fix: improve heartbeat accountId routing (#8702) (thanks @lsh411) * fix: harden heartbeat accountId routing (#8702) (thanks @lsh411) * fix: expose heartbeat accountId in status (#8702) (thanks @lsh411) * chore: format status + heartbeat tests (#8702) (thanks @lsh411) --------- Co-authored-by: m1 16 512 Co-authored-by: Gustavo Madeira Santana --- CHANGELOG.md | 1 + docs/gateway/heartbeat.md | 31 ++++++++ src/commands/status.command.ts | 27 ++++++- src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/infra/heartbeat-events.ts | 1 + ...espects-ackmaxchars-heartbeat-acks.test.ts | 74 +++++++++++++++++++ ...tbeat-runner.returns-default-unset.test.ts | 74 ++++++++++++++++++- src/infra/heartbeat-runner.ts | 22 ++++++ src/infra/outbound/targets.ts | 43 +++++++++-- 10 files changed, 267 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b823c02b..14a65faed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. - Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard. - Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo. - Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 88782f58ae..1d10d7a3a8 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -87,6 +87,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. includeReasoning: false, // default: false (deliver separate Reasoning: message when available) target: "last", // last | none | (core or plugin, e.g. "bluebubbles") to: "+15551234567", // optional channel-specific override + accountId: "ops-bot", // optional multi-account channel id prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", ackMaxChars: 300, // max chars allowed after HEARTBEAT_OK }, @@ -136,6 +137,35 @@ Example: two agents, only the second agent runs heartbeats. } ``` +### Multi account example + +Use `accountId` to target a specific account on multi-account channels like Telegram: + +```json5 +{ + agents: { + list: [ + { + id: "ops", + heartbeat: { + every: "1h", + target: "telegram", + to: "12345678", + accountId: "ops-bot", + }, + }, + ], + }, + channels: { + telegram: { + accounts: { + "ops-bot": { botToken: "YOUR_TELEGRAM_BOT_TOKEN" }, + }, + }, + }, +} +``` + ### Field notes - `every`: heartbeat interval (duration string; default unit = minutes). @@ -150,6 +180,7 @@ Example: two agents, only the second agent runs heartbeats. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none`: run the heartbeat but **do not deliver** externally. - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). +- `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a82cd6b0df..a8e288b82f 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,3 +1,4 @@ +import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatCliCommand } from "../cli/command-format.js"; import { withProgress } from "../cli/progress.js"; @@ -120,6 +121,14 @@ export async function statusCommand( }), ) : undefined; + const lastHeartbeat = + opts.deep && gatewayReachable + ? await callGateway({ + method: "last-heartbeat", + params: {}, + timeoutMs: opts.timeoutMs, + }).catch(() => null) + : null; const configChannel = normalizeUpdateChannel(cfg.update?.channel); const channelInfo = resolveEffectiveUpdateChannel({ @@ -157,7 +166,7 @@ export async function statusCommand( nodeService: nodeDaemon, agents: agentStatus, securityAudit, - ...(health || usage ? { health, usage } : {}), + ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), }, null, 2, @@ -275,6 +284,21 @@ export async function statusCommand( .filter(Boolean); return parts.length > 0 ? parts.join(", ") : "disabled"; })(); + const lastHeartbeatValue = (() => { + if (!opts.deep) { + return null; + } + if (!gatewayReachable) { + return warn("unavailable"); + } + if (!lastHeartbeat) { + return muted("none"); + } + const age = formatAge(Date.now() - lastHeartbeat.ts); + const channel = lastHeartbeat.channel ?? "unknown"; + const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null; + return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · "); + })(); const storeLabel = summary.sessions.paths.length > 1 @@ -371,6 +395,7 @@ export async function statusCommand( { Item: "Probes", Value: probesValue }, { Item: "Events", Value: eventsValue }, { Item: "Heartbeat", Value: heartbeatValue }, + ...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []), { Item: "Sessions", Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 0008c71734..27b24eace2 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -182,6 +182,8 @@ export type AgentDefaultsConfig = { target?: "last" | "none" | ChannelId; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; + /** Optional account id for multi-account channels. */ + accountId?: string; /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */ prompt?: string; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index c63742218c..c2e792f32f 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -24,6 +24,7 @@ export const HeartbeatSchema = z includeReasoning: z.boolean().optional(), target: z.string().optional(), to: z.string().optional(), + accountId: z.string().optional(), prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), }) diff --git a/src/infra/heartbeat-events.ts b/src/infra/heartbeat-events.ts index 3055079c80..e16860e5d4 100644 --- a/src/infra/heartbeat-events.ts +++ b/src/infra/heartbeat-events.ts @@ -4,6 +4,7 @@ export type HeartbeatEventPayload = { ts: number; status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed"; to?: string; + accountId?: string; preview?: string; durationMs?: number; hasMedia?: boolean; diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index 88c6b98e8e..c7b75455d7 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -483,6 +483,80 @@ describe("resolveHeartbeatIntervalMs", () => { } }); + it("uses explicit heartbeat accountId for telegram delivery", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "telegram", accountId: "work" }, + }, + }, + channels: { + telegram: { + accounts: { + work: { botToken: "test-bot-token-123" }, + }, + }, + }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: "123456", + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + chatId: "123456", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendTelegram, + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "123456", + "Hello from heartbeat", + expect.objectContaining({ accountId: "work", verbose: false }), + ); + } finally { + replySpy.mockRestore(); + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("does not pre-resolve telegram accountId (allows config-only account tokens)", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index a43ff68340..18729628d2 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -25,7 +25,10 @@ import { resolveHeartbeatPrompt, runHeartbeatOnce, } from "./heartbeat-runner.js"; -import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js"; +import { + resolveHeartbeatDeliveryTarget, + resolveHeartbeatSenderContext, +} from "./outbound/targets.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); @@ -264,6 +267,42 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); }); + it("uses explicit heartbeat accountId when provided", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { target: "telegram", to: "123", accountId: "work" }, + }, + }, + channels: { telegram: { accounts: { work: { botToken: "token" } } } }, + }; + expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ + channel: "telegram", + to: "123", + accountId: "work", + lastChannel: undefined, + lastAccountId: undefined, + }); + }); + + it("skips when explicit heartbeat accountId is unknown", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { target: "telegram", to: "123", accountId: "missing" }, + }, + }, + channels: { telegram: { accounts: { work: { botToken: "token" } } } }, + }; + expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ + channel: "none", + reason: "unknown-account", + accountId: "missing", + lastChannel: undefined, + lastAccountId: undefined, + }); + }); + it("prefers per-agent heartbeat overrides when provided", () => { const cfg: OpenClawConfig = { agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } }, @@ -285,6 +324,39 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); }); +describe("resolveHeartbeatSenderContext", () => { + it("prefers delivery accountId for allowFrom resolution", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + allowFrom: ["111"], + accounts: { + work: { allowFrom: ["222"], botToken: "token" }, + }, + }, + }, + }; + const entry = { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "telegram" as const, + lastTo: "111", + lastAccountId: "default", + }; + const delivery = { + channel: "telegram" as const, + to: "999", + accountId: "work", + lastChannel: "telegram" as const, + lastAccountId: "default", + }; + + const ctx = resolveHeartbeatSenderContext({ cfg, entry, delivery }); + + expect(ctx.allowFrom).toEqual(["222"]); + }); +}); + describe("runHeartbeatOnce", () => { it("skips when agent heartbeat is not enabled", async () => { const cfg: OpenClawConfig = { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index e9648edba7..a73c575954 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -534,6 +534,19 @@ export async function runHeartbeatOnce(opts: { const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); + const heartbeatAccountId = heartbeat?.accountId?.trim(); + if (delivery.reason === "unknown-account") { + log.warn("heartbeat: unknown accountId", { + accountId: delivery.accountId ?? heartbeatAccountId ?? null, + target: heartbeat?.target ?? "last", + }); + } else if (heartbeatAccountId) { + log.info("heartbeat: using explicit accountId", { + accountId: delivery.accountId ?? heartbeatAccountId, + target: heartbeat?.target ?? "last", + channel: delivery.channel, + }); + } const visibility = delivery.channel !== "none" ? resolveHeartbeatVisibility({ @@ -569,6 +582,7 @@ export async function runHeartbeatOnce(opts: { reason: "alerts-disabled", durationMs: Date.now() - startedAt, channel: delivery.channel !== "none" ? delivery.channel : undefined, + accountId: delivery.accountId, }); return { status: "skipped", reason: "alerts-disabled" }; } @@ -626,6 +640,7 @@ export async function runHeartbeatOnce(opts: { reason: opts.reason, durationMs: Date.now() - startedAt, channel: delivery.channel !== "none" ? delivery.channel : undefined, + accountId: delivery.accountId, silent: !okSent, indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined, }); @@ -659,6 +674,7 @@ export async function runHeartbeatOnce(opts: { reason: opts.reason, durationMs: Date.now() - startedAt, channel: delivery.channel !== "none" ? delivery.channel : undefined, + accountId: delivery.accountId, silent: !okSent, indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined, }); @@ -695,6 +711,7 @@ export async function runHeartbeatOnce(opts: { durationMs: Date.now() - startedAt, hasMedia: false, channel: delivery.channel !== "none" ? delivery.channel : undefined, + accountId: delivery.accountId, }); return { status: "ran", durationMs: Date.now() - startedAt }; } @@ -714,6 +731,7 @@ export async function runHeartbeatOnce(opts: { preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, + accountId: delivery.accountId, }); return { status: "ran", durationMs: Date.now() - startedAt }; } @@ -731,6 +749,7 @@ export async function runHeartbeatOnce(opts: { durationMs: Date.now() - startedAt, channel: delivery.channel, hasMedia: mediaUrls.length > 0, + accountId: delivery.accountId, indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); return { status: "ran", durationMs: Date.now() - startedAt }; @@ -752,6 +771,7 @@ export async function runHeartbeatOnce(opts: { durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, channel: delivery.channel, + accountId: delivery.accountId, }); log.info("heartbeat: channel not ready", { channel: delivery.channel, @@ -801,6 +821,7 @@ export async function runHeartbeatOnce(opts: { durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, channel: delivery.channel, + accountId: delivery.accountId, indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined, }); return { status: "ran", durationMs: Date.now() - startedAt }; @@ -811,6 +832,7 @@ export async function runHeartbeatOnce(opts: { reason, durationMs: Date.now() - startedAt, channel: delivery.channel !== "none" ? delivery.channel : undefined, + accountId: delivery.accountId, indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined, }); log.error(`heartbeat failed: ${reason}`, { error: reason }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index f2703e2b80..ce3359309c 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -8,6 +8,7 @@ import type { } from "../../utils/message-channel.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { formatCliCommand } from "../../cli/command-format.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import { INTERNAL_MESSAGE_CHANNEL, @@ -207,11 +208,37 @@ export function resolveHeartbeatDeliveryTarget(params: { mode: "heartbeat", }); + const heartbeatAccountId = heartbeat?.accountId?.trim(); + // Use explicit accountId from heartbeat config if provided, otherwise fall back to session + let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId; + + if (heartbeatAccountId && resolvedTarget.channel) { + const plugin = getChannelPlugin(resolvedTarget.channel); + const listAccountIds = plugin?.config.listAccountIds; + const accountIds = listAccountIds ? listAccountIds(cfg) : []; + if (accountIds.length > 0) { + const normalizedAccountId = normalizeAccountId(heartbeatAccountId); + const normalizedAccountIds = new Set( + accountIds.map((accountId) => normalizeAccountId(accountId)), + ); + if (!normalizedAccountIds.has(normalizedAccountId)) { + return { + channel: "none", + reason: "unknown-account", + accountId: normalizedAccountId, + lastChannel: resolvedTarget.lastChannel, + lastAccountId: resolvedTarget.lastAccountId, + }; + } + effectiveAccountId = normalizedAccountId; + } + } + if (!resolvedTarget.channel || !resolvedTarget.to) { return { channel: "none", reason: "no-target", - accountId: resolvedTarget.accountId, + accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, }; @@ -221,14 +248,14 @@ export function resolveHeartbeatDeliveryTarget(params: { channel: resolvedTarget.channel, to: resolvedTarget.to, cfg, - accountId: resolvedTarget.accountId, + accountId: effectiveAccountId, mode: "heartbeat", }); if (!resolved.ok) { return { channel: "none", reason: "no-target", - accountId: resolvedTarget.accountId, + accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, }; @@ -241,7 +268,7 @@ export function resolveHeartbeatDeliveryTarget(params: { channel: resolvedTarget.channel, to: resolvedTarget.to, cfg, - accountId: resolvedTarget.accountId, + accountId: effectiveAccountId, mode: "explicit", }); if (explicit.ok && explicit.to !== resolved.to) { @@ -253,7 +280,7 @@ export function resolveHeartbeatDeliveryTarget(params: { channel: resolvedTarget.channel, to: resolved.to, reason, - accountId: resolvedTarget.accountId, + accountId: effectiveAccountId, lastChannel: resolvedTarget.lastChannel, lastAccountId: resolvedTarget.lastAccountId, }; @@ -301,11 +328,13 @@ export function resolveHeartbeatSenderContext(params: { }): HeartbeatSenderContext { const provider = params.delivery.channel !== "none" ? params.delivery.channel : params.delivery.lastChannel; + const accountId = + params.delivery.accountId ?? + (provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined); const allowFrom = provider ? (getChannelPlugin(provider)?.config.resolveAllowFrom?.({ cfg: params.cfg, - accountId: - provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined, + accountId, }) ?? []) : [];