feat: move heartbeat config to agent.heartbeat
This commit is contained in:
@@ -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 () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
|
||||
@@ -169,14 +169,25 @@ export async function getReplyFromConfig(
|
||||
const agentCfg = cfg.agent;
|
||||
const sessionCfg = cfg.session;
|
||||
|
||||
const { provider: defaultProvider, model: defaultModel } =
|
||||
resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const mainModel = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const defaultProvider = mainModel.provider;
|
||||
const defaultModel = mainModel.model;
|
||||
let provider = defaultProvider;
|
||||
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 =
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(model) ??
|
||||
|
||||
@@ -5,6 +5,8 @@ import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { z } from "zod";
|
||||
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionConfig = {
|
||||
@@ -305,7 +307,6 @@ export type ClawdisConfig = {
|
||||
skillsInstall?: SkillsInstallConfig;
|
||||
models?: ModelsConfig;
|
||||
agent?: {
|
||||
/** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */
|
||||
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
||||
model?: string;
|
||||
/** 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). */
|
||||
mediaMaxMb?: number;
|
||||
typingIntervalSeconds?: number;
|
||||
/** Periodic background heartbeat runs (minutes). 0 disables. */
|
||||
heartbeatMinutes?: number;
|
||||
/** Periodic background heartbeat runs. */
|
||||
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). */
|
||||
maxConcurrent?: number;
|
||||
/** Bash tool defaults. */
|
||||
@@ -444,6 +450,25 @@ const MessagesSchema = z
|
||||
})
|
||||
.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
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
@@ -581,7 +606,7 @@ const ClawdisSchema = z.object({
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
heartbeatMinutes: z.number().nonnegative().optional(),
|
||||
heartbeat: HeartbeatSchema,
|
||||
maxConcurrent: z.number().int().positive().optional(),
|
||||
bash: z
|
||||
.object({
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
HEARTBEAT_TOKEN,
|
||||
monitorWebProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
resolveReplyHeartbeatMinutes,
|
||||
resolveReplyHeartbeatIntervalMs,
|
||||
runWebHeartbeatOnce,
|
||||
SILENT_REPLY_TOKEN,
|
||||
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 = {};
|
||||
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
|
||||
expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull();
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
agent: { heartbeatMinutes: 5 },
|
||||
resolveReplyHeartbeatIntervalMs({
|
||||
agent: { heartbeat: { every: "5m" } },
|
||||
}),
|
||||
).toBe(5);
|
||||
).toBe(5 * 60_000);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
agent: { heartbeatMinutes: 0 },
|
||||
resolveReplyHeartbeatIntervalMs({
|
||||
agent: { heartbeat: { every: "0m" } },
|
||||
}),
|
||||
).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(() => ({
|
||||
agent: { heartbeatMinutes: 0.001 },
|
||||
routing: {
|
||||
allowFrom: ["+4367"],
|
||||
},
|
||||
@@ -774,7 +778,7 @@ describe("web auto-reply", () => {
|
||||
replyResolver,
|
||||
runtime,
|
||||
controller.signal,
|
||||
{ replyHeartbeatMinutes: 1, replyHeartbeatNow: true },
|
||||
{ replyHeartbeatEvery: "1m", replyHeartbeatNow: true },
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -833,7 +837,7 @@ describe("web auto-reply", () => {
|
||||
replyResolver,
|
||||
runtime,
|
||||
controller.signal,
|
||||
{ replyHeartbeatMinutes: 10_000 },
|
||||
{ replyHeartbeatEvery: "10000m" },
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { waitForever } from "../cli/wait.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -72,7 +73,7 @@ type WebInboundMsg = Parameters<
|
||||
export type WebMonitorTuning = {
|
||||
reconnect?: Partial<ReconnectPolicy>;
|
||||
heartbeatSeconds?: number;
|
||||
replyHeartbeatMinutes?: number;
|
||||
replyHeartbeatEvery?: string;
|
||||
replyHeartbeatNow?: boolean;
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
statusSink?: (status: WebProviderStatus) => void;
|
||||
@@ -81,7 +82,6 @@ export type WebMonitorTuning = {
|
||||
const formatDuration = (ms: number) =>
|
||||
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||
|
||||
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
||||
|
||||
@@ -188,14 +188,22 @@ function debugMention(
|
||||
return { wasMentioned: result, details };
|
||||
}
|
||||
|
||||
export function resolveReplyHeartbeatMinutes(
|
||||
export function resolveReplyHeartbeatIntervalMs(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
overrideMinutes?: number,
|
||||
overrideEvery?: string,
|
||||
) {
|
||||
const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes;
|
||||
if (raw === 0) return null;
|
||||
if (typeof raw === "number" && raw > 0) return raw;
|
||||
return DEFAULT_REPLY_HEARTBEAT_MINUTES;
|
||||
const raw = overrideEvery ?? cfg.agent?.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 stripHeartbeatToken(raw?: string) {
|
||||
@@ -767,9 +775,9 @@ export async function monitorWebProvider(
|
||||
cfg,
|
||||
tuning.heartbeatSeconds,
|
||||
);
|
||||
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes(
|
||||
const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs(
|
||||
cfg,
|
||||
tuning.replyHeartbeatMinutes,
|
||||
tuning.replyHeartbeatEvery,
|
||||
);
|
||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||
const mentionConfig = buildMentionConfig(cfg);
|
||||
@@ -939,7 +947,7 @@ export async function monitorWebProvider(
|
||||
let lastInboundMsg: WebInboundMsg | null = null;
|
||||
|
||||
// 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 WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
|
||||
|
||||
@@ -1428,7 +1436,7 @@ export async function monitorWebProvider(
|
||||
}
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
if (!replyHeartbeatMinutes) {
|
||||
if (!replyHeartbeatIntervalMs) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
let heartbeatInboundMsg = lastInboundMsg;
|
||||
@@ -1510,7 +1518,7 @@ export async function monitorWebProvider(
|
||||
{
|
||||
connectionId,
|
||||
to: heartbeatInboundMsg.from,
|
||||
intervalMinutes: replyHeartbeatMinutes,
|
||||
intervalMs: replyHeartbeatIntervalMs,
|
||||
sessionKey: snapshot.key,
|
||||
sessionId: snapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: snapshot.fresh,
|
||||
@@ -1635,8 +1643,8 @@ export async function monitorWebProvider(
|
||||
|
||||
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
|
||||
|
||||
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
|
||||
const intervalMs = replyHeartbeatMinutes * 60_000;
|
||||
if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) {
|
||||
const intervalMs = replyHeartbeatIntervalMs;
|
||||
replyHeartbeatTimer = setInterval(() => {
|
||||
if (!heartbeatsEnabled) return;
|
||||
void runReplyHeartbeat();
|
||||
|
||||
Reference in New Issue
Block a user