diff --git a/src/commands/health.ts b/src/commands/health.ts index ddfc308bda4..301cb55282e 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -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"; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index c0344065126..2045c380e1b 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -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", diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b028c99ab6d..6de3b282648 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -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"; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 1f6ae8767e9..34b3a7b5f86 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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); } diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts new file mode 100644 index 00000000000..89650de44a6 --- /dev/null +++ b/src/infra/heartbeat-summary.ts @@ -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, + }; +}