feat: add per-agent heartbeat config
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||||
- Onboarding: prompt to modify/disable/delete when reconfiguring existing channel accounts and keep channel selection looping until Finished.
|
- Onboarding: prompt to modify/disable/delete when reconfiguring existing channel accounts and keep channel selection looping until Finished.
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
- TUI: show provider/model labels for the active session and default model.
|
||||||
|
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||||
|
|||||||
@@ -296,8 +296,9 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
||||||
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
|
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
|
||||||
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
|
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
|
||||||
|
- Uses the configured heartbeat prompt (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.`) + `HEARTBEAT_OK` skip behavior.
|
||||||
- Delivery defaults to the last used channel (or configured target).
|
- Delivery defaults to the last used channel (or configured target).
|
||||||
|
|
||||||
## Reconnect behavior
|
## Reconnect behavior
|
||||||
@@ -330,6 +331,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
- `agents.defaults.heartbeat.model` (optional override)
|
- `agents.defaults.heartbeat.model` (optional override)
|
||||||
- `agents.defaults.heartbeat.target`
|
- `agents.defaults.heartbeat.target`
|
||||||
- `agents.defaults.heartbeat.to`
|
- `agents.defaults.heartbeat.to`
|
||||||
|
- `agents.list[].heartbeat.*` (per-agent overrides)
|
||||||
- `session.*` (scope, idle, store, mainKey)
|
- `session.*` (scope, idle, store, mainKey)
|
||||||
- `web.enabled` (disable channel startup when false)
|
- `web.enabled` (disable channel startup when false)
|
||||||
- `web.heartbeatSeconds`
|
- `web.heartbeatSeconds`
|
||||||
|
|||||||
@@ -1675,9 +1675,14 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
|||||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
- `prompt`: optional override for the heartbeat 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.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
||||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||||
|
|
||||||
|
Per-agent heartbeats:
|
||||||
|
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
|
||||||
|
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
|
||||||
|
become the shared baseline for those agents.
|
||||||
|
|
||||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ Example config:
|
|||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
|
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable).
|
||||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
|
||||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
`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.`
|
||||||
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||||
prompt includes a “Heartbeat” section and the run is flagged internally.
|
prompt includes a “Heartbeat” section and the run is flagged internally.
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
|
|||||||
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
|
||||||
|
|
||||||
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
|
||||||
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a
|
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or
|
||||||
custom body (sent verbatim).
|
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim).
|
||||||
|
|
||||||
## Response contract
|
## Response contract
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
|||||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||||
to: "+15551234567", // optional channel-specific override
|
to: "+15551234567", // optional channel-specific override
|
||||||
prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
|
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
|
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,39 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Per-agent heartbeats
|
||||||
|
|
||||||
|
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
||||||
|
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
|
||||||
|
(so you can set shared defaults once and override per agent).
|
||||||
|
|
||||||
|
Example: two agents, only the second agent runs heartbeats.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "30m",
|
||||||
|
target: "last"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{ id: "main", default: true },
|
||||||
|
{
|
||||||
|
id: "ops",
|
||||||
|
heartbeat: {
|
||||||
|
every: "1h",
|
||||||
|
target: "whatsapp",
|
||||||
|
to: "+15551234567",
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Field notes
|
### Field notes
|
||||||
|
|
||||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||||
@@ -100,7 +133,8 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
|||||||
|
|
||||||
## Delivery behavior
|
## Delivery behavior
|
||||||
|
|
||||||
- Heartbeats run in the **main session** (`main`, or `global` when scope is global).
|
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
|
||||||
|
when `session.scope = "global"`.
|
||||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||||
- If `target` resolves to no external destination, the run still happens but no
|
- If `target` resolves to no external destination, the run still happens but no
|
||||||
outbound message is sent.
|
outbound message is sent.
|
||||||
@@ -149,6 +183,9 @@ You can enqueue a system event and trigger an immediate heartbeat with:
|
|||||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||||
|
agent heartbeats immediately.
|
||||||
|
|
||||||
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
Use `--mode next-heartbeat` to wait for the next scheduled tick.
|
||||||
|
|
||||||
## Reasoning delivery (optional)
|
## Reasoning delivery (optional)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
|
|||||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||||
|
|
||||||
Default heartbeat prompt:
|
Default heartbeat prompt:
|
||||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
`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.`
|
||||||
|
|
||||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ Example:
|
|||||||
## Heartbeats (proactive mode)
|
## Heartbeats (proactive mode)
|
||||||
|
|
||||||
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
`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.`
|
||||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||||
|
|
||||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ read_when:
|
|||||||
- Elevated mode docs live in [Elevated mode](/tools/elevated).
|
- Elevated mode docs live in [Elevated mode](/tools/elevated).
|
||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
- Heartbeat probe body is the configured heartbeat prompt (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.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
|
||||||
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`.
|
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`.
|
||||||
|
|
||||||
## Web chat UI
|
## Web chat UI
|
||||||
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type ResolvedAgentConfig = {
|
|||||||
model?: AgentEntry["model"];
|
model?: AgentEntry["model"];
|
||||||
memorySearch?: AgentEntry["memorySearch"];
|
memorySearch?: AgentEntry["memorySearch"];
|
||||||
humanDelay?: AgentEntry["humanDelay"];
|
humanDelay?: AgentEntry["humanDelay"];
|
||||||
|
heartbeat?: AgentEntry["heartbeat"];
|
||||||
identity?: AgentEntry["identity"];
|
identity?: AgentEntry["identity"];
|
||||||
groupChat?: AgentEntry["groupChat"];
|
groupChat?: AgentEntry["groupChat"];
|
||||||
subagents?: AgentEntry["subagents"];
|
subagents?: AgentEntry["subagents"];
|
||||||
@@ -89,6 +90,7 @@ export function resolveAgentConfig(
|
|||||||
: undefined,
|
: undefined,
|
||||||
memorySearch: entry.memorySearch,
|
memorySearch: entry.memorySearch,
|
||||||
humanDelay: entry.humanDelay,
|
humanDelay: entry.humanDelay,
|
||||||
|
heartbeat: entry.heartbeat,
|
||||||
identity: entry.identity,
|
identity: entry.identity,
|
||||||
groupChat: entry.groupChat,
|
groupChat: entry.groupChat,
|
||||||
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
|
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export type AgentConfig = {
|
|||||||
memorySearch?: MemorySearchConfig;
|
memorySearch?: MemorySearchConfig;
|
||||||
/** Human-like delay between block replies for this agent. */
|
/** Human-like delay between block replies for this agent. */
|
||||||
humanDelay?: HumanDelayConfig;
|
humanDelay?: HumanDelayConfig;
|
||||||
|
/** Optional per-agent heartbeat overrides. */
|
||||||
|
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||||
identity?: IdentityConfig;
|
identity?: IdentityConfig;
|
||||||
groupChat?: GroupChatConfig;
|
groupChat?: GroupChatConfig;
|
||||||
subagents?: {
|
subagents?: {
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export const AgentEntrySchema = z.object({
|
|||||||
model: AgentModelSchema.optional(),
|
model: AgentModelSchema.optional(),
|
||||||
memorySearch: MemorySearchSchema,
|
memorySearch: MemorySearchSchema,
|
||||||
humanDelay: HumanDelaySchema.optional(),
|
humanDelay: HumanDelaySchema.optional(),
|
||||||
|
heartbeat: HeartbeatSchema,
|
||||||
identity: IdentitySchema,
|
identity: IdentitySchema,
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
subagents: z
|
subagents: z
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import * as replyModule from "../auto-reply/reply.js";
|
import * as replyModule from "../auto-reply/reply.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||||
|
|
||||||
// Avoid pulling optional runtime deps during isolated runs.
|
// Avoid pulling optional runtime deps during isolated runs.
|
||||||
@@ -15,22 +16,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
main: {
|
|
||||||
sessionId: "sid",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "whatsapp",
|
|
||||||
lastTo: "+1555",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -45,6 +30,23 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
||||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
@@ -75,22 +77,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
main: {
|
|
||||||
sessionId: "sid",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "whatsapp",
|
|
||||||
lastTo: "+1555",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -104,6 +90,23 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
|
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
|
||||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
@@ -136,22 +139,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
try {
|
try {
|
||||||
const originalUpdatedAt = 1000;
|
const originalUpdatedAt = 1000;
|
||||||
const bumpedUpdatedAt = 2000;
|
const bumpedUpdatedAt = 2000;
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
main: {
|
|
||||||
sessionId: "sid",
|
|
||||||
updatedAt: originalUpdatedAt,
|
|
||||||
lastProvider: "whatsapp",
|
|
||||||
lastTo: "+1555",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -165,12 +152,32 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: originalUpdatedAt,
|
||||||
|
lastProvider: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
replySpy.mockImplementationOnce(async () => {
|
replySpy.mockImplementationOnce(async () => {
|
||||||
const raw = await fs.readFile(storePath, "utf-8");
|
const raw = await fs.readFile(storePath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as { main?: { updatedAt?: number } };
|
const parsed = JSON.parse(raw) as Record<string, { updatedAt?: number } | undefined>;
|
||||||
if (parsed.main) {
|
if (parsed[sessionKey]) {
|
||||||
parsed.main.updatedAt = bumpedUpdatedAt;
|
parsed[sessionKey] = {
|
||||||
|
...parsed[sessionKey],
|
||||||
|
updatedAt: bumpedUpdatedAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
await fs.writeFile(storePath, JSON.stringify(parsed, null, 2));
|
await fs.writeFile(storePath, JSON.stringify(parsed, null, 2));
|
||||||
return { text: "" };
|
return { text: "" };
|
||||||
@@ -186,10 +193,11 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
|
const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
|
||||||
main?: { updatedAt?: number };
|
string,
|
||||||
};
|
{ updatedAt?: number } | undefined
|
||||||
expect(finalStore.main?.updatedAt).toBe(bumpedUpdatedAt);
|
>;
|
||||||
|
expect(finalStore[sessionKey]?.updatedAt).toBe(bumpedUpdatedAt);
|
||||||
} finally {
|
} finally {
|
||||||
replySpy.mockRestore();
|
replySpy.mockRestore();
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
@@ -201,11 +209,22 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
try {
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
main: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
@@ -217,16 +236,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
session: { store: storePath },
|
|
||||||
};
|
|
||||||
|
|
||||||
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
|
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
|
||||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
messageId: "m1",
|
messageId: "m1",
|
||||||
@@ -260,11 +269,22 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||||
try {
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
main: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastProvider: "telegram",
|
lastProvider: "telegram",
|
||||||
@@ -276,16 +296,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
|
||||||
session: { store: storePath },
|
|
||||||
};
|
|
||||||
|
|
||||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||||
const sendTelegram = vi.fn().mockResolvedValue({
|
const sendTelegram = vi.fn().mockResolvedValue({
|
||||||
messageId: "m1",
|
messageId: "m1",
|
||||||
@@ -325,22 +335,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
main: {
|
|
||||||
sessionId: "sid",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "telegram",
|
|
||||||
lastTo: "123456",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -356,6 +350,23 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "telegram",
|
||||||
|
lastTo: "123456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||||
const sendTelegram = vi.fn().mockResolvedValue({
|
const sendTelegram = vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as replyModule from "../auto-reply/reply.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveAgentMainSessionKey,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
@@ -55,6 +56,16 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(2 * 60 * 60_000);
|
).toBe(2 * 60 * 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses explicit heartbeat overrides when provided", () => {
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatIntervalMs(
|
||||||
|
{ agents: { defaults: { heartbeat: { every: "30m" } } } },
|
||||||
|
undefined,
|
||||||
|
{ every: "5m" },
|
||||||
|
),
|
||||||
|
).toBe(5 * 60_000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveHeartbeatPrompt", () => {
|
describe("resolveHeartbeatPrompt", () => {
|
||||||
@@ -183,6 +194,23 @@ describe("resolveHeartbeatDeliveryTarget", () => {
|
|||||||
to: "123",
|
to: "123",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers per-agent heartbeat overrides when provided", () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
|
||||||
|
};
|
||||||
|
const heartbeat = { target: "whatsapp", to: "+1555" } as const;
|
||||||
|
expect(
|
||||||
|
resolveHeartbeatDeliveryTarget({
|
||||||
|
cfg,
|
||||||
|
entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1999" },
|
||||||
|
heartbeat,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runHeartbeatOnce", () => {
|
describe("runHeartbeatOnce", () => {
|
||||||
@@ -191,11 +219,22 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
try {
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
main: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastChannel: "whatsapp",
|
lastChannel: "whatsapp",
|
||||||
@@ -207,16 +246,6 @@ describe("runHeartbeatOnce", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
session: { store: storePath },
|
|
||||||
};
|
|
||||||
|
|
||||||
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
|
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
|
||||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
messageId: "m1",
|
messageId: "m1",
|
||||||
@@ -242,6 +271,76 @@ describe("runHeartbeatOnce", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses per-agent heartbeat overrides and session keys", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: { every: "30m", prompt: "Default prompt" },
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{ id: "main", default: true },
|
||||||
|
{
|
||||||
|
id: "ops",
|
||||||
|
heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" });
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
agentId: "ops",
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
||||||
|
expect(replySpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ Body: "Ops check", SessionKey: sessionKey }),
|
||||||
|
{ isHeartbeat: true },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
@@ -302,22 +401,6 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
main: {
|
|
||||||
sessionId: "sid",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "whatsapp",
|
|
||||||
lastTo: "+1555",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -332,6 +415,23 @@ describe("runHeartbeatOnce", () => {
|
|||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
replySpy.mockResolvedValue([
|
replySpy.mockResolvedValue([
|
||||||
{ text: "Reasoning:\n_Because it helps_" },
|
{ text: "Reasoning:\n_Because it helps_" },
|
||||||
@@ -372,22 +472,6 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(
|
|
||||||
storePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
main: {
|
|
||||||
sessionId: "sid",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
lastProvider: "whatsapp",
|
|
||||||
lastTo: "+1555",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -402,6 +486,23 @@ describe("runHeartbeatOnce", () => {
|
|||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
};
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastProvider: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
replySpy.mockResolvedValue([
|
replySpy.mockResolvedValue([
|
||||||
{ text: "Reasoning:\n_Because it helps_" },
|
{ text: "Reasoning:\n_Because it helps_" },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
@@ -14,20 +15,22 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
resolveMainSessionKey,
|
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
||||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||||
import {
|
import {
|
||||||
type HeartbeatRunResult,
|
type HeartbeatRunResult,
|
||||||
|
type HeartbeatWakeHandler,
|
||||||
requestHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
setHeartbeatWakeHandler,
|
setHeartbeatWakeHandler,
|
||||||
} from "./heartbeat-wake.js";
|
} from "./heartbeat-wake.js";
|
||||||
@@ -49,8 +52,48 @@ export function setHeartbeatsEnabled(enabled: boolean) {
|
|||||||
heartbeatsEnabled = enabled;
|
heartbeatsEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?: string) {
|
type HeartbeatConfig = AgentDefaultsConfig["heartbeat"];
|
||||||
const raw = overrideEvery ?? cfg.agents?.defaults?.heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY;
|
type HeartbeatAgent = {
|
||||||
|
agentId: string;
|
||||||
|
heartbeat?: HeartbeatConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveHeartbeatConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
agentId?: string,
|
||||||
|
): HeartbeatConfig | undefined {
|
||||||
|
const defaults = cfg.agents?.defaults?.heartbeat;
|
||||||
|
if (!agentId) return defaults;
|
||||||
|
const overrides = resolveAgentConfig(cfg, agentId)?.heartbeat;
|
||||||
|
if (!defaults && !overrides) return overrides;
|
||||||
|
return { ...defaults, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHeartbeatAgents(cfg: ClawdbotConfig): HeartbeatAgent[] {
|
||||||
|
const list = cfg.agents?.list ?? [];
|
||||||
|
const explicit = list.filter((entry) => entry?.heartbeat);
|
||||||
|
if (explicit.length > 0) {
|
||||||
|
return explicit
|
||||||
|
.map((entry) => {
|
||||||
|
const id = normalizeAgentId(entry.id);
|
||||||
|
return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.agentId);
|
||||||
|
}
|
||||||
|
const fallbackId = resolveDefaultAgentId(cfg);
|
||||||
|
return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatIntervalMs(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
overrideEvery?: string,
|
||||||
|
heartbeat?: HeartbeatConfig,
|
||||||
|
) {
|
||||||
|
const raw =
|
||||||
|
overrideEvery ??
|
||||||
|
heartbeat?.every ??
|
||||||
|
cfg.agents?.defaults?.heartbeat?.every ??
|
||||||
|
DEFAULT_HEARTBEAT_EVERY;
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const trimmed = String(raw).trim();
|
const trimmed = String(raw).trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -64,23 +107,31 @@ export function resolveHeartbeatIntervalMs(cfg: ClawdbotConfig, overrideEvery?:
|
|||||||
return ms;
|
return ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) {
|
export function resolveHeartbeatPrompt(cfg: ClawdbotConfig, heartbeat?: HeartbeatConfig) {
|
||||||
return resolveHeartbeatPromptText(cfg.agents?.defaults?.heartbeat?.prompt);
|
return resolveHeartbeatPromptText(
|
||||||
}
|
heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt,
|
||||||
|
|
||||||
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) {
|
|
||||||
return Math.max(
|
|
||||||
0,
|
|
||||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHeartbeatSession(cfg: ClawdbotConfig) {
|
function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatConfig) {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
heartbeat?.ackMaxChars ??
|
||||||
|
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
|
||||||
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
const sessionKey = scope === "global" ? "global" : resolveMainSessionKey(cfg);
|
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
|
||||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
const sessionKey =
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
scope === "global"
|
||||||
|
? "global"
|
||||||
|
: resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
|
||||||
|
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store[sessionKey];
|
const entry = store[sessionKey];
|
||||||
return { sessionKey, storePath, store, entry };
|
return { sessionKey, storePath, store, entry };
|
||||||
@@ -186,14 +237,18 @@ function normalizeHeartbeatReply(
|
|||||||
|
|
||||||
export async function runHeartbeatOnce(opts: {
|
export async function runHeartbeatOnce(opts: {
|
||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
|
agentId?: string;
|
||||||
|
heartbeat?: HeartbeatConfig;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
deps?: HeartbeatDeps;
|
deps?: HeartbeatDeps;
|
||||||
}): Promise<HeartbeatRunResult> {
|
}): Promise<HeartbeatRunResult> {
|
||||||
const cfg = opts.cfg ?? loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg));
|
||||||
|
const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId);
|
||||||
if (!heartbeatsEnabled) {
|
if (!heartbeatsEnabled) {
|
||||||
return { status: "skipped", reason: "disabled" };
|
return { status: "skipped", reason: "disabled" };
|
||||||
}
|
}
|
||||||
if (!resolveHeartbeatIntervalMs(cfg)) {
|
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
|
||||||
return { status: "skipped", reason: "disabled" };
|
return { status: "skipped", reason: "disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +258,9 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry });
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||||
const lastChannel =
|
const lastChannel =
|
||||||
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||||
? normalizeChannelId(entry.lastChannel)
|
? normalizeChannelId(entry.lastChannel)
|
||||||
@@ -222,18 +277,19 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
provider: senderProvider,
|
provider: senderProvider,
|
||||||
});
|
});
|
||||||
const prompt = resolveHeartbeatPrompt(cfg);
|
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
||||||
const ctx = {
|
const ctx = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
From: sender,
|
From: sender,
|
||||||
To: sender,
|
To: sender,
|
||||||
Provider: "heartbeat",
|
Provider: "heartbeat",
|
||||||
|
SessionKey: sessionKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
|
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
|
||||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||||
const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
|
const includeReasoning = heartbeat?.includeReasoning === true;
|
||||||
const reasoningPayloads = includeReasoning
|
const reasoningPayloads = includeReasoning
|
||||||
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
||||||
: [];
|
: [];
|
||||||
@@ -255,10 +311,10 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg);
|
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
||||||
const normalized = normalizeHeartbeatReply(
|
const normalized = normalizeHeartbeatReply(
|
||||||
replyPayload,
|
replyPayload,
|
||||||
resolveEffectiveMessagesConfig(cfg, resolveAgentIdFromSessionKey(sessionKey)).responsePrefix,
|
resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
|
||||||
ackMaxChars,
|
ackMaxChars,
|
||||||
);
|
);
|
||||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||||
@@ -409,19 +465,57 @@ export function startHeartbeatRunner(opts: {
|
|||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}) {
|
}) {
|
||||||
const cfg = opts.cfg ?? loadConfig();
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
const intervalMs = resolveHeartbeatIntervalMs(cfg);
|
const heartbeatAgents = resolveHeartbeatAgents(cfg);
|
||||||
|
const intervals = heartbeatAgents
|
||||||
|
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
|
||||||
|
.filter((value): value is number => typeof value === "number");
|
||||||
|
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
|
||||||
if (!intervalMs) {
|
if (!intervalMs) {
|
||||||
log.info("heartbeat: disabled", { enabled: false });
|
log.info("heartbeat: disabled", { enabled: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
const run = async (params?: { reason?: string }) => {
|
const lastRunByAgent = new Map<string, number>();
|
||||||
|
const run: HeartbeatWakeHandler = async (params) => {
|
||||||
|
if (!heartbeatsEnabled) {
|
||||||
|
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||||
|
}
|
||||||
|
if (heartbeatAgents.length === 0) {
|
||||||
|
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = params?.reason;
|
||||||
|
const isInterval = reason === "interval";
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const now = startedAt;
|
||||||
|
let ran = false;
|
||||||
|
|
||||||
|
for (const agent of heartbeatAgents) {
|
||||||
|
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||||
|
if (!agentIntervalMs) continue;
|
||||||
|
const lastRun = lastRunByAgent.get(agent.agentId);
|
||||||
|
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await runHeartbeatOnce({
|
const res = await runHeartbeatOnce({
|
||||||
cfg,
|
cfg,
|
||||||
reason: params?.reason,
|
agentId: agent.agentId,
|
||||||
|
heartbeat: agent.heartbeat,
|
||||||
|
reason,
|
||||||
deps: { runtime },
|
deps: { runtime },
|
||||||
});
|
});
|
||||||
|
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||||
return res;
|
return res;
|
||||||
|
}
|
||||||
|
if (res.status !== "skipped" || res.reason !== "disabled") {
|
||||||
|
lastRunByAgent.set(agent.agentId, now);
|
||||||
|
}
|
||||||
|
if (res.status === "ran") ran = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
|
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
||||||
};
|
};
|
||||||
|
|
||||||
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind
|
|||||||
import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
import type { ChannelId, ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
|
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
|
||||||
import type {
|
import type {
|
||||||
DeliverableMessageChannel,
|
DeliverableMessageChannel,
|
||||||
GatewayMessageChannel,
|
GatewayMessageChannel,
|
||||||
@@ -79,9 +80,11 @@ export function resolveOutboundTarget(params: {
|
|||||||
export function resolveHeartbeatDeliveryTarget(params: {
|
export function resolveHeartbeatDeliveryTarget(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
entry?: SessionEntry;
|
entry?: SessionEntry;
|
||||||
|
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||||
}): OutboundTarget {
|
}): OutboundTarget {
|
||||||
const { cfg, entry } = params;
|
const { cfg, entry } = params;
|
||||||
const rawTarget = cfg.agents?.defaults?.heartbeat?.target;
|
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
|
||||||
|
const rawTarget = heartbeat?.target;
|
||||||
let target: HeartbeatTarget = "last";
|
let target: HeartbeatTarget = "last";
|
||||||
if (rawTarget === "none" || rawTarget === "last") {
|
if (rawTarget === "none" || rawTarget === "last") {
|
||||||
target = rawTarget;
|
target = rawTarget;
|
||||||
@@ -95,10 +98,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const explicitTo =
|
const explicitTo =
|
||||||
typeof cfg.agents?.defaults?.heartbeat?.to === "string" &&
|
typeof heartbeat?.to === "string" && heartbeat.to.trim() ? heartbeat.to.trim() : undefined;
|
||||||
cfg.agents.defaults.heartbeat.to.trim()
|
|
||||||
? cfg.agents.defaults.heartbeat.to.trim()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const lastChannel =
|
const lastChannel =
|
||||||
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||||
|
|||||||
Reference in New Issue
Block a user