Status: split heartbeat summary helpers

This commit is contained in:
Vincent Koc 2026-03-15 21:26:15 -07:00
parent 0a6f22a694
commit ebfd32efc3
5 changed files with 133 additions and 105 deletions

View File

@ -14,7 +14,7 @@ import { formatErrorMessage } from "../infra/errors.js";
import {
type HeartbeatSummary,
resolveHeartbeatSummaryForAgent,
} from "../infra/heartbeat-runner.js";
} from "../infra/heartbeat-summary.js";
import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";

View File

@ -48,7 +48,7 @@ vi.mock("../infra/channel-summary.js", () => ({
buildChannelSummary: vi.fn(async () => ["ok"]),
}));
vi.mock("../infra/heartbeat-runner.js", () => ({
vi.mock("../infra/heartbeat-summary.js", () => ({
resolveHeartbeatSummaryForAgent: vi.fn(() => ({
enabled: true,
every: "5m",

View File

@ -16,7 +16,7 @@ import {
listAgentsForGateway,
resolveSessionModelRef,
} from "../gateway/session-utils.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { resolveRuntimeServiceVersion } from "../version.js";

View File

@ -11,7 +11,6 @@ import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
DEFAULT_HEARTBEAT_EVERY,
isHeartbeatContentEffectivelyEmpty,
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
stripHeartbeatToken,
@ -21,7 +20,6 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
@ -56,6 +54,12 @@ import {
} from "./heartbeat-events-filter.js";
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js";
import {
isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs,
resolveHeartbeatSummaryForAgent,
type HeartbeatSummary,
} from "./heartbeat-summary.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
import {
areHeartbeatsEnabled,
@ -84,6 +88,12 @@ export type HeartbeatDeps = OutboundSendDeps &
const log = createSubsystemLogger("gateway/heartbeat");
export { areHeartbeatsEnabled, setHeartbeatsEnabled };
export {
isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs,
resolveHeartbeatSummaryForAgent,
type HeartbeatSummary,
} from "./heartbeat-summary.js";
type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
type HeartbeatAgent = {
@ -91,17 +101,6 @@ type HeartbeatAgent = {
heartbeat?: HeartbeatConfig;
};
export type HeartbeatSummary = {
enabled: boolean;
every: string;
everyMs: number | null;
prompt: string;
target: string;
model?: string;
ackMaxChars: number;
};
const DEFAULT_HEARTBEAT_TARGET = "none";
export { isCronSystemEvent };
type HeartbeatAgentState = {
@ -122,18 +121,6 @@ function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) {
return list.some((entry) => Boolean(entry?.heartbeat));
}
export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean {
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
const list = cfg.agents?.list ?? [];
const hasExplicit = hasExplicitHeartbeatAgents(cfg);
if (hasExplicit) {
return list.some(
(entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
);
}
return resolvedAgentId === resolveDefaultAgentId(cfg);
}
function resolveHeartbeatConfig(
cfg: OpenClawConfig,
agentId?: string,
@ -149,54 +136,6 @@ function resolveHeartbeatConfig(
return { ...defaults, ...overrides };
}
export function resolveHeartbeatSummaryForAgent(
cfg: OpenClawConfig,
agentId?: string,
): HeartbeatSummary {
const defaults = cfg.agents?.defaults?.heartbeat;
const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined;
const enabled = isHeartbeatEnabledForAgent(cfg, agentId);
if (!enabled) {
return {
enabled: false,
every: "disabled",
everyMs: null,
prompt: resolveHeartbeatPromptText(defaults?.prompt),
target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET,
model: defaults?.model,
ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS),
};
}
const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined;
const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY;
const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged);
const prompt = resolveHeartbeatPromptText(
merged?.prompt ?? defaults?.prompt ?? overrides?.prompt,
);
const target =
merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET;
const model = merged?.model ?? defaults?.model ?? overrides?.model;
const ackMaxChars = Math.max(
0,
merged?.ackMaxChars ??
defaults?.ackMaxChars ??
overrides?.ackMaxChars ??
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
return {
enabled: true,
every,
everyMs,
prompt,
target,
model,
ackMaxChars,
};
}
function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] {
const list = cfg.agents?.list ?? [];
if (hasExplicitHeartbeatAgents(cfg)) {
@ -212,35 +151,6 @@ function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] {
return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }];
}
export function resolveHeartbeatIntervalMs(
cfg: OpenClawConfig,
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;
}
let ms: number;
try {
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
} catch {
return null;
}
if (ms <= 0) {
return null;
}
return ms;
}
export function resolveHeartbeatPrompt(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) {
return resolveHeartbeatPromptText(heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt);
}

View File

@ -0,0 +1,118 @@
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
DEFAULT_HEARTBEAT_EVERY,
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
} from "../auto-reply/heartbeat.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { normalizeAgentId } from "../routing/session-key.js";
type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
export type HeartbeatSummary = {
enabled: boolean;
every: string;
everyMs: number | null;
prompt: string;
target: string;
model?: string;
ackMaxChars: number;
};
const DEFAULT_HEARTBEAT_TARGET = "none";
function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) {
const list = cfg.agents?.list ?? [];
return list.some((entry) => Boolean(entry?.heartbeat));
}
export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean {
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
const list = cfg.agents?.list ?? [];
const hasExplicit = hasExplicitHeartbeatAgents(cfg);
if (hasExplicit) {
return list.some(
(entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
);
}
return resolvedAgentId === resolveDefaultAgentId(cfg);
}
export function resolveHeartbeatIntervalMs(
cfg: OpenClawConfig,
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;
}
let ms: number;
try {
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
} catch {
return null;
}
if (ms <= 0) {
return null;
}
return ms;
}
export function resolveHeartbeatSummaryForAgent(
cfg: OpenClawConfig,
agentId?: string,
): HeartbeatSummary {
const defaults = cfg.agents?.defaults?.heartbeat;
const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined;
const enabled = isHeartbeatEnabledForAgent(cfg, agentId);
if (!enabled) {
return {
enabled: false,
every: "disabled",
everyMs: null,
prompt: resolveHeartbeatPromptText(defaults?.prompt),
target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET,
model: defaults?.model,
ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS),
};
}
const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined;
const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY;
const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged);
const prompt = resolveHeartbeatPromptText(
merged?.prompt ?? defaults?.prompt ?? overrides?.prompt,
);
const target =
merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET;
const model = merged?.model ?? defaults?.model ?? overrides?.model;
const ackMaxChars = Math.max(
0,
merged?.ackMaxChars ??
defaults?.ackMaxChars ??
overrides?.ackMaxChars ??
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
return {
enabled: true,
every,
everyMs,
prompt,
target,
model,
ackMaxChars,
};
}