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 <m116512@m1ui-MacBookAir-2.local>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
This commit is contained in:
lsh411
2026-02-05 06:49:12 +09:00
committed by GitHub
parent bebf323775
commit a42e3cb78a
10 changed files with 267 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}) ?? [])
: [];