feat: move heartbeat config to agent.heartbeat

This commit is contained in:
Peter Steinberger
2025-12-26 01:13:13 +01:00
parent 1ef888ca23
commit 9f7b1f0942
10 changed files with 138 additions and 47 deletions

View File

@@ -13,6 +13,7 @@
### Breaking ### Breaking
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read. - Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
### Fixes ### Fixes
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam. - Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.

View File

@@ -19,7 +19,7 @@ Youre putting an agent in a position to:
Start conservative: Start conservative:
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac). - Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
- Use a dedicated WhatsApp number for the assistant. - Use a dedicated WhatsApp number for the assistant.
- Keep heartbeats disabled until you trust the setup (`heartbeatMinutes: 0`). - Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
## Prerequisites ## Prerequisites
@@ -122,7 +122,7 @@ Example:
thinkingDefault: "high", thinkingDefault: "high",
timeoutSeconds: 1800, timeoutSeconds: 1800,
// Start with 0; enable later. // Start with 0; enable later.
heartbeatMinutes: 0 heartbeat: { every: "0m" }
}, },
routing: { routing: {
allowFrom: ["+15555550123"], allowFrom: ["+15555550123"],
@@ -148,14 +148,14 @@ Example:
## Heartbeats (proactive mode) ## Heartbeats (proactive mode)
When `agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`). When `agent.heartbeat.every` is set to a positive interval, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`).
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat. - If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat.
```json5 ```json5
{ {
agent: { agent: {
heartbeatMinutes: 30 heartbeat: { every: "30m" }
} }
} }
``` ```

View File

@@ -129,7 +129,9 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
verboseDefault: "off", verboseDefault: "off",
timeoutSeconds: 600, timeoutSeconds: 600,
mediaMaxMb: 5, mediaMaxMb: 5,
heartbeatMinutes: 30, heartbeat: {
every: "30m"
},
maxConcurrent: 3, maxConcurrent: 3,
bash: { bash: {
backgroundMs: 20000, backgroundMs: 20000,
@@ -145,6 +147,11 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary
deprecation fallback. deprecation fallback.
`agent.heartbeat` configures periodic heartbeat runs:
- `every`: duration string (`ms`, `s`, `m`); default unit minutes. Omit or set
`0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`).
`agent.bash` configures background bash defaults: `agent.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 20000) - `backgroundMs`: time before auto-background (ms, default 20000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800)

View File

@@ -12,8 +12,10 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts. - Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
## Config & defaults ## Config & defaults
- New config key: `agent.heartbeatMinutes` (number of minutes; `0` disables). - New config key: `agent.heartbeat` with:
- Default: 30 minutes. - `every`: duration string (`ms`, `s`, `m`; default unit minutes). `0m` disables.
- `model`: optional override model (`provider/model`) for heartbeat runs.
- Default: disabled unless `agent.heartbeat.every` is set.
- New optional idle override for heartbeats: `session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works. - New optional idle override for heartbeats: `session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
## Poller behavior ## Poller behavior
@@ -40,7 +42,7 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line. - Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
## Documentation ## Documentation
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule. - Add a short README snippet under configuration showing `agent.heartbeat` and the sentinel rule.
- Expose CLI triggers: - Expose CLI triggers:
- `clawdis heartbeat` (web provider, defaults to first `routing.allowFrom`; optional `--to` override) - `clawdis heartbeat` (web provider, defaults to first `routing.allowFrom`; optional `--to` override)
- `--session-id <uuid>` forces resuming a specific session for that heartbeat - `--session-id <uuid>` forces resuming a specific session for that heartbeat

View File

@@ -86,7 +86,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
## Heartbeats ## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Reply heartbeat** asks agent on a timer (`agent.heartbeatMinutes`). - **Reply heartbeat** asks agent on a timer (`agent.heartbeat.every`).
- Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior. - Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior.
- Skips if queue busy or last inbound was a group. - Skips if queue busy or last inbound was a group.
- Falls back to last direct recipient if needed. - Falls back to last direct recipient if needed.
@@ -104,7 +104,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
- `messages.messagePrefix` (inbound prefix) - `messages.messagePrefix` (inbound prefix)
- `messages.responsePrefix` (outbound prefix) - `messages.responsePrefix` (outbound prefix)
- `agent.mediaMaxMb` - `agent.mediaMaxMb`
- `agent.heartbeatMinutes` - `agent.heartbeat.every`
- `agent.heartbeat.model` (optional override)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`
- `web.reconnect.*` - `web.reconnect.*`

View File

@@ -101,6 +101,38 @@ describe("trigger handling", () => {
}); });
}); });
it("uses heartbeat model override for heartbeat runs", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = makeCfg(home);
cfg.agent = {
...cfg.agent,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
};
await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{ isHeartbeat: true },
cfg,
);
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.provider).toBe("anthropic");
expect(call?.model).toBe("claude-haiku-4-5-20251001");
});
});
it("updates group activation when the owner sends /activation", async () => { it("updates group activation when the owner sends /activation", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);

View File

@@ -169,14 +169,25 @@ export async function getReplyFromConfig(
const agentCfg = cfg.agent; const agentCfg = cfg.agent;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const { provider: defaultProvider, model: defaultModel } = const mainModel = resolveConfiguredModelRef({
resolveConfiguredModelRef({ cfg,
cfg, defaultProvider: DEFAULT_PROVIDER,
defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL,
defaultModel: DEFAULT_MODEL, });
}); const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
let provider = defaultProvider; let provider = defaultProvider;
let model = defaultModel; let model = defaultModel;
if (opts?.isHeartbeat) {
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
const heartbeatRef = heartbeatRaw
? parseModelRef(heartbeatRaw, defaultProvider)
: null;
if (heartbeatRef) {
provider = heartbeatRef.provider;
model = heartbeatRef.model;
}
}
let contextTokens = let contextTokens =
agentCfg?.contextTokens ?? agentCfg?.contextTokens ??
lookupContextTokens(model) ?? lookupContextTokens(model) ??

View File

@@ -5,6 +5,8 @@ import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { z } from "zod"; import { z } from "zod";
import { parseDurationMs } from "../cli/parse-duration.js";
export type SessionScope = "per-sender" | "global"; export type SessionScope = "per-sender" | "global";
export type SessionConfig = { export type SessionConfig = {
@@ -305,7 +307,6 @@ export type ClawdisConfig = {
skillsInstall?: SkillsInstallConfig; skillsInstall?: SkillsInstallConfig;
models?: ModelsConfig; models?: ModelsConfig;
agent?: { agent?: {
/** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ /** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
model?: string; model?: string;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */ /** Agent working directory (preferred). Used as the default cwd for agent runs. */
@@ -322,8 +323,13 @@ export type ClawdisConfig = {
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number; mediaMaxMb?: number;
typingIntervalSeconds?: number; typingIntervalSeconds?: number;
/** Periodic background heartbeat runs (minutes). 0 disables. */ /** Periodic background heartbeat runs. */
heartbeatMinutes?: number; heartbeat?: {
/** Heartbeat interval (duration string, default unit: minutes). */
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
};
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
maxConcurrent?: number; maxConcurrent?: number;
/** Bash tool defaults. */ /** Bash tool defaults. */
@@ -444,6 +450,25 @@ const MessagesSchema = z
}) })
.optional(); .optional();
const HeartbeatSchema = z
.object({
every: z.string().optional(),
model: z.string().optional(),
})
.superRefine((val, ctx) => {
if (!val.every) return;
try {
parseDurationMs(val.every, { defaultUnit: "m" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["every"],
message: "invalid duration (use ms, s, m)",
});
}
})
.optional();
const RoutingSchema = z const RoutingSchema = z
.object({ .object({
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
@@ -581,7 +606,7 @@ const ClawdisSchema = z.object({
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(), mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),
heartbeatMinutes: z.number().nonnegative().optional(), heartbeat: HeartbeatSchema,
maxConcurrent: z.number().int().positive().optional(), maxConcurrent: z.number().int().positive().optional(),
bash: z bash: z
.object({ .object({

View File

@@ -21,7 +21,7 @@ import {
HEARTBEAT_TOKEN, HEARTBEAT_TOKEN,
monitorWebProvider, monitorWebProvider,
resolveHeartbeatRecipients, resolveHeartbeatRecipients,
resolveReplyHeartbeatMinutes, resolveReplyHeartbeatIntervalMs,
runWebHeartbeatOnce, runWebHeartbeatOnce,
SILENT_REPLY_TOKEN, SILENT_REPLY_TOKEN,
stripHeartbeatToken, stripHeartbeatToken,
@@ -157,20 +157,25 @@ describe("heartbeat helpers", () => {
}); });
}); });
it("resolves heartbeat minutes with default and overrides", () => { it("resolves reply heartbeat interval from config and overrides", () => {
const cfgBase: ClawdisConfig = {}; const cfgBase: ClawdisConfig = {};
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull();
expect( expect(
resolveReplyHeartbeatMinutes({ resolveReplyHeartbeatIntervalMs({
agent: { heartbeatMinutes: 5 }, agent: { heartbeat: { every: "5m" } },
}), }),
).toBe(5); ).toBe(5 * 60_000);
expect( expect(
resolveReplyHeartbeatMinutes({ resolveReplyHeartbeatIntervalMs({
agent: { heartbeatMinutes: 0 }, agent: { heartbeat: { every: "0m" } },
}), }),
).toBeNull(); ).toBeNull();
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); expect(resolveReplyHeartbeatIntervalMs(cfgBase, "7m")).toBe(7 * 60_000);
expect(
resolveReplyHeartbeatIntervalMs({
agent: { heartbeat: { every: "5" } },
}),
).toBe(5 * 60_000);
}); });
}); });
@@ -506,7 +511,6 @@ describe("runWebHeartbeatOnce", () => {
); );
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
agent: { heartbeatMinutes: 0.001 },
routing: { routing: {
allowFrom: ["+4367"], allowFrom: ["+4367"],
}, },
@@ -774,7 +778,7 @@ describe("web auto-reply", () => {
replyResolver, replyResolver,
runtime, runtime,
controller.signal, controller.signal,
{ replyHeartbeatMinutes: 1, replyHeartbeatNow: true }, { replyHeartbeatEvery: "1m", replyHeartbeatNow: true },
); );
try { try {
@@ -833,7 +837,7 @@ describe("web auto-reply", () => {
replyResolver, replyResolver,
runtime, runtime,
controller.signal, controller.signal,
{ replyHeartbeatMinutes: 10_000 }, { replyHeartbeatEvery: "10000m" },
); );
try { try {

View File

@@ -7,6 +7,7 @@ import {
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { waitForever } from "../cli/wait.js"; import { waitForever } from "../cli/wait.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import {
@@ -72,7 +73,7 @@ type WebInboundMsg = Parameters<
export type WebMonitorTuning = { export type WebMonitorTuning = {
reconnect?: Partial<ReconnectPolicy>; reconnect?: Partial<ReconnectPolicy>;
heartbeatSeconds?: number; heartbeatSeconds?: number;
replyHeartbeatMinutes?: number; replyHeartbeatEvery?: string;
replyHeartbeatNow?: boolean; replyHeartbeatNow?: boolean;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>; sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
statusSink?: (status: WebProviderStatus) => void; statusSink?: (status: WebProviderStatus) => void;
@@ -81,7 +82,6 @@ export type WebMonitorTuning = {
const formatDuration = (ms: number) => const formatDuration = (ms: number) =>
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
export const HEARTBEAT_PROMPT = "HEARTBEAT"; export const HEARTBEAT_PROMPT = "HEARTBEAT";
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN }; export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
@@ -188,14 +188,22 @@ function debugMention(
return { wasMentioned: result, details }; return { wasMentioned: result, details };
} }
export function resolveReplyHeartbeatMinutes( export function resolveReplyHeartbeatIntervalMs(
cfg: ReturnType<typeof loadConfig>, cfg: ReturnType<typeof loadConfig>,
overrideMinutes?: number, overrideEvery?: string,
) { ) {
const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes; const raw = overrideEvery ?? cfg.agent?.heartbeat?.every;
if (raw === 0) return null; if (!raw) return null;
if (typeof raw === "number" && raw > 0) return raw; const trimmed = String(raw).trim();
return DEFAULT_REPLY_HEARTBEAT_MINUTES; 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 stripHeartbeatToken(raw?: string) { export function stripHeartbeatToken(raw?: string) {
@@ -767,9 +775,9 @@ export async function monitorWebProvider(
cfg, cfg,
tuning.heartbeatSeconds, tuning.heartbeatSeconds,
); );
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes( const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs(
cfg, cfg,
tuning.replyHeartbeatMinutes, tuning.replyHeartbeatEvery,
); );
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const mentionConfig = buildMentionConfig(cfg); const mentionConfig = buildMentionConfig(cfg);
@@ -939,7 +947,7 @@ export async function monitorWebProvider(
let lastInboundMsg: WebInboundMsg | null = null; let lastInboundMsg: WebInboundMsg | null = null;
// Watchdog to detect stuck message processing (e.g., event emitter died) // Watchdog to detect stuck message processing (e.g., event emitter died)
// Should be significantly longer than heartbeatMinutes to avoid false positives // Should be significantly longer than the reply heartbeat interval to avoid false positives
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
@@ -1428,7 +1436,7 @@ export async function monitorWebProvider(
} }
return { status: "skipped", reason: "requests-in-flight" }; return { status: "skipped", reason: "requests-in-flight" };
} }
if (!replyHeartbeatMinutes) { if (!replyHeartbeatIntervalMs) {
return { status: "skipped", reason: "disabled" }; return { status: "skipped", reason: "disabled" };
} }
let heartbeatInboundMsg = lastInboundMsg; let heartbeatInboundMsg = lastInboundMsg;
@@ -1510,7 +1518,7 @@ export async function monitorWebProvider(
{ {
connectionId, connectionId,
to: heartbeatInboundMsg.from, to: heartbeatInboundMsg.from,
intervalMinutes: replyHeartbeatMinutes, intervalMs: replyHeartbeatIntervalMs,
sessionKey: snapshot.key, sessionKey: snapshot.key,
sessionId: snapshot.entry?.sessionId ?? null, sessionId: snapshot.entry?.sessionId ?? null,
sessionFresh: snapshot.fresh, sessionFresh: snapshot.fresh,
@@ -1635,8 +1643,8 @@ export async function monitorWebProvider(
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat()); setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
if (replyHeartbeatMinutes && !replyHeartbeatTimer) { if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) {
const intervalMs = replyHeartbeatMinutes * 60_000; const intervalMs = replyHeartbeatIntervalMs;
replyHeartbeatTimer = setInterval(() => { replyHeartbeatTimer = setInterval(() => {
if (!heartbeatsEnabled) return; if (!heartbeatsEnabled) return;
void runReplyHeartbeat(); void runReplyHeartbeat();