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

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

View File

@@ -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 | <channel id> (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.

View File

@@ -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<HeartbeatEventPayload | null>({
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}`,

View File

@@ -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). */

View File

@@ -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(),
})

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