Add structured heartbeat responses and Codex tool replies

* Add structured heartbeat response tool

* agents: default codex replies to tools

* agents: use flat heartbeat tool enums
This commit is contained in:
pashpashpash
2026-05-01 11:30:41 -07:00
committed by GitHub
parent bee47a8be9
commit 439d8edf68
39 changed files with 780 additions and 25 deletions

View File

@@ -27,6 +27,8 @@ Docs: https://docs.openclaw.ai
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.
- CLI/proxy: add `openclaw proxy validate` so operators can verify effective proxy configuration, proxy reachability, and expected allow/deny destination behavior before deploying proxy-routed OpenClaw commands. (#73438) Thanks @jesse-merhi.
- Agents/Codex: default Codex app-server dynamic tools to native-first, keeping OpenClaw integration tools while leaving file, patch, exec, and process ownership to the Codex harness. (#75308) Thanks @pashpashpash.
- Agents/Codex: default Codex-harness direct source replies to the OpenClaw `message` tool when visible reply delivery is not explicitly configured, keeping channel-visible output as a deliberate tool call. Thanks @pashpashpash.
- Heartbeats/agents: add a structured `heartbeat_respond` tool for tool-capable heartbeat runs so agents can record quiet outcomes or explicit notification text without relying only on `HEARTBEAT_OK` parsing. Thanks @pashpashpash.
### Fixes

View File

@@ -1,2 +1,2 @@
37787172adf7a55a32097599b4bf5729fc7138c8743c6f4c9d58fc8d01df72a1 plugin-sdk-api-baseline.json
0ec4957528477832085c638a5f7f691c878ba199f3e81f330f162c27cfd9ebf4 plugin-sdk-api-baseline.jsonl
42cb8c8be1e10a42891035fc402022ab1cc2eb941e7e69a9a2f8a6d01a30bd3e plugin-sdk-api-baseline.json
4bafb4519802e0daeb8458d9aed9b09d2fc51755f02d1568a368d814c6f7930a plugin-sdk-api-baseline.jsonl

View File

@@ -47,7 +47,7 @@ If the message tool is unavailable under the active tool policy, OpenClaw falls
back to automatic visible replies instead of silently suppressing the response.
`openclaw doctor` warns about this mismatch.
For direct chats and any other source turn, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
For direct chats and any other source turn, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Harnesses can also choose this as their unset default; the Codex harness does this for Codex-mode direct chats. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.

View File

@@ -774,7 +774,7 @@ See the full channel index: [Channels](/channels).
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. To apply the same tool-only visible-reply behavior to direct chats too, set `messages.visibleReplies: "message_tool"`.
Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. To apply the same tool-only visible-reply behavior to direct chats too, set `messages.visibleReplies: "message_tool"`; the Codex harness also uses that tool-only behavior as its unset direct-chat default.
If the message tool is unavailable under the active tool policy, OpenClaw falls back to automatic visible replies instead of silently suppressing the response. `openclaw doctor` warns about this mismatch.
@@ -789,7 +789,7 @@ The gateway hot-reloads `messages` config after the file is saved. Restart only
```json5
{
messages: {
visibleReplies: "automatic", // global default for direct/source chats
visibleReplies: "automatic", // global default for direct/source chats; Codex harness defaults unset direct chats to message_tool
groupChat: {
historyLimit: 50,
visibleReplies: "message_tool", // default; use "automatic" for legacy final replies
@@ -803,7 +803,7 @@ The gateway hot-reloads `messages` config after the file is saved. Restart only
`messages.groupChat.historyLimit` sets the global default. Channels can override with `channels.<channel>.historyLimit` (or per-account). Set `0` to disable.
`messages.visibleReplies` is the global source-turn default; `messages.groupChat.visibleReplies` overrides it for group/channel source turns. Channel allowlists and mention gating still decide whether a turn is processed.
`messages.visibleReplies` is the global source-turn default; `messages.groupChat.visibleReplies` overrides it for group/channel source turns. When `messages.visibleReplies` is unset, a harness can provide its own direct/source default; the Codex harness defaults to `message_tool`. Channel allowlists and mention gating still decide whether a turn is processed.
#### DM history limits

View File

@@ -82,6 +82,7 @@ If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub
## Response contract
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
- Tool-capable heartbeat runs may instead call `heartbeat_respond` with `notify: false` for no visible update, or `notify: true` plus `notificationText` for an alert. When present, the structured tool response takes precedence over the text fallback.
- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is **`ackMaxChars`** (default: 300).
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated specially.
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.

View File

@@ -15,6 +15,17 @@ discovery, native thread resume, native compaction, and app-server execution.
OpenClaw still owns chat channels, session files, model selection, tools,
approvals, media delivery, and the visible transcript mirror.
When a source chat turn runs through the Codex harness, visible replies default
to the OpenClaw `message` tool if the deployment has not explicitly configured
`messages.visibleReplies`. The agent can still finish its Codex turn privately;
it only posts to the channel when it calls `message(action="send")`. Set
`messages.visibleReplies: "automatic"` to keep direct-chat final replies on the
legacy automatic delivery path.
Codex heartbeat turns also get the `heartbeat_respond` tool by default, so the
agent can record whether the wake should stay quiet or notify without encoding
that control flow in final text.
If you are trying to orient yourself, start with
[Agent runtimes](/concepts/agent-runtimes). The short version is:
`openai/gpt-5.5` is the model ref, `codex` is the runtime, and Telegram,
@@ -583,7 +594,8 @@ Codex dynamic tools default to the `native-first` profile. In that mode,
OpenClaw does not expose dynamic tools that duplicate Codex-native workspace
operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and
`update_plan`. OpenClaw integration tools such as messaging, sessions, media,
cron, browser, nodes, gateway, and `web_search` remain available.
cron, browser, nodes, gateway, `heartbeat_respond`, and `web_search` remain
available.
Supported top-level Codex plugin fields:

View File

@@ -23,6 +23,9 @@ export function createCodexAppServerAgentHarness(options?: {
return {
id: options?.id ?? "codex",
label: options?.label ?? "Codex agent harness",
deliveryDefaults: {
sourceVisibleReplies: "message_tool",
},
supports: (ctx) => {
const provider = ctx.provider.trim().toLowerCase();
if (providerIds.has(provider)) {

View File

@@ -44,6 +44,7 @@ describe("codex plugin", () => {
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
id: "codex",
label: "Codex agent harness",
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
dispose: expect.any(Function),
});
expect(registerMediaUnderstandingProvider.mock.calls[0]?.[0]).toMatchObject({
@@ -89,6 +90,7 @@ describe("codex plugin", () => {
it("only claims the codex provider by default", () => {
const harness = createCodexAppServerAgentHarness();
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
.supported,

View File

@@ -1,6 +1,9 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import { wrapToolWithBeforeToolCallHook } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -212,6 +215,38 @@ describe("createCodexDynamicToolBridge", () => {
});
});
it("records heartbeat response tool outcomes", async () => {
const bridge = createBridgeWithToolResult(
HEARTBEAT_RESPONSE_TOOL_NAME,
textToolResult("Recorded.", {
status: "recorded",
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
}),
);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
arguments: {},
});
expect(result).toEqual(expectInputText("Recorded."));
expect(bridge.telemetry.heartbeatToolResponse).toEqual({
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
});
});
it("applies agent tool result middleware from the active plugin registry", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(

View File

@@ -5,11 +5,14 @@ import {
createCodexAppServerToolResultExtensionRunner,
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
HEARTBEAT_RESPONSE_TOOL_NAME,
isToolWrappedWithBeforeToolCallHook,
isMessagingTool,
isMessagingToolSendAction,
normalizeHeartbeatToolResponse,
runAgentHarnessAfterToolCallHook,
type AnyAgentTool,
type HeartbeatToolResponse,
type MessagingToolSend,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -32,6 +35,7 @@ export type CodexDynamicToolBridge = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
successfulCronAdds?: number;
@@ -190,6 +194,12 @@ function collectToolTelemetry(params: {
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
}
if (!params.isError && params.toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
const response = normalizeHeartbeatToolResponse(params.result?.details);
if (response) {
params.telemetry.heartbeatToolResponse = response;
}
}
if (!params.isError && params.result) {
const media = extractToolResultMediaArtifact(params.result);
if (media) {
@@ -256,6 +266,7 @@ function isToolResultError(result: AgentToolResult<unknown>): boolean {
status !== "ok" &&
status !== "success" &&
status !== "completed" &&
status !== "recorded" &&
status !== "running"
);
}

View File

@@ -15,6 +15,7 @@ import {
type AgentMessage,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type HeartbeatToolResponse,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { readCodexTurn } from "./protocol-validators.js";
@@ -32,6 +33,7 @@ export type CodexAppServerToolTelemetry = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
@@ -232,6 +234,7 @@ export class CodexAppServerEventProjector {
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
toolMediaUrls: toolTelemetry.toolMediaUrls,
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,

View File

@@ -360,12 +360,14 @@ describe("runCodexAppServerAttempt", () => {
"update_plan",
"web_search",
"message",
"heartbeat_respond",
"sessions_spawn",
].map((name) => ({ name }));
expect(__testing.applyCodexDynamicToolProfile(tools, {}).map((tool) => tool.name)).toEqual([
"web_search",
"message",
"heartbeat_respond",
"sessions_spawn",
]);
});

View File

@@ -1398,6 +1398,8 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
enableHeartbeatTool: params.trigger === "heartbeat",
forceHeartbeatTool: params.trigger === "heartbeat",
onYield: (message) => {
input.onYieldDetected();
emitCodexAppServerEvent(params, {

View File

@@ -27,10 +27,19 @@ export type AgentHarnessResultClassification =
| "ok"
| NonNullable<AgentHarnessAttemptResult["agentHarnessResultClassification"]>;
export type AgentHarnessDeliveryDefaults = {
/**
* Preferred default for visible source replies when user config has not
* explicitly selected automatic or message-tool delivery.
*/
sourceVisibleReplies?: "automatic" | "message_tool";
};
export type AgentHarness = {
id: string;
label: string;
pluginId?: string;
deliveryDefaults?: AgentHarnessDeliveryDefaults;
supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport;
runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>;
classify?(

View File

@@ -20,6 +20,7 @@ import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createHeartbeatResponseTool } from "./tools/heartbeat-response-tool.js";
import { createImageGenerateTool } from "./tools/image-generate-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
@@ -95,6 +96,8 @@ export function createOpenClawTools(
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
enableHeartbeatTool?: boolean;
/** If true, skip plugin tool resolution and return only shipped core tools. */
disablePluginTools?: boolean;
/** Trusted sender id from inbound context (not tool args). */
@@ -215,6 +218,7 @@ export function createOpenClawTools(
requesterSenderId: options?.requesterSenderId ?? undefined,
senderIsOwner: options?.senderIsOwner,
});
const heartbeatTool = options?.enableHeartbeatTool ? createHeartbeatResponseTool() : null;
const nodesToolBase = createNodesTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
@@ -255,6 +259,7 @@ export function createOpenClawTools(
}),
]),
...(!embedded && messageTool ? [messageTool] : []),
...collectPresentOpenClawTools([heartbeatTool]),
createTtsTool({
agentChannel: options?.agentChannel,
config: resolvedConfig,

View File

@@ -2058,6 +2058,7 @@ export async function runEmbeddedPiAgent(
inlineToolResultsAllowed: false,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
heartbeatToolResponse: attempt.heartbeatToolResponse,
});
const payloadsWithToolMedia = mergeAttemptToolMediaPayloads({
payloads,
@@ -2120,6 +2121,7 @@ export async function runEmbeddedPiAgent(
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
heartbeatToolResponse: attempt.heartbeatToolResponse,
successfulCronAdds: attempt.successfulCronAdds,
};
}
@@ -2312,6 +2314,7 @@ export async function runEmbeddedPiAgent(
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
heartbeatToolResponse: attempt.heartbeatToolResponse,
successfulCronAdds: attempt.successfulCronAdds,
};
}
@@ -2362,6 +2365,7 @@ export async function runEmbeddedPiAgent(
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
heartbeatToolResponse: attempt.heartbeatToolResponse,
successfulCronAdds: attempt.successfulCronAdds,
};
}
@@ -2471,6 +2475,7 @@ export async function runEmbeddedPiAgent(
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
heartbeatToolResponse: attempt.heartbeatToolResponse,
successfulCronAdds: attempt.successfulCronAdds,
};
}
@@ -2590,6 +2595,7 @@ export async function runEmbeddedPiAgent(
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
heartbeatToolResponse: attempt.heartbeatToolResponse,
successfulCronAdds: attempt.successfulCronAdds,
};
}

View File

@@ -94,6 +94,7 @@ export function createSubscriptionMock(): SubscriptionMock {
getMessagingToolSentTexts: () => [] as string[],
getMessagingToolSentMediaUrls: () => [] as string[],
getMessagingToolSentTargets: () => [] as MessagingToolSend[],
getHeartbeatToolResponse: () => undefined,
getPendingToolMediaReply: () => null,
getSuccessfulCronAdds: () => 0,
getReplayState: () => ({

View File

@@ -2217,6 +2217,7 @@ export async function runEmbeddedAttempt(
getMessagingToolSentTexts,
getMessagingToolSentMediaUrls,
getMessagingToolSentTargets,
getHeartbeatToolResponse,
getPendingToolMediaReply,
getSuccessfulCronAdds,
getReplayState,
@@ -3437,6 +3438,7 @@ export async function runEmbeddedAttempt(
messagingToolSentTexts: getMessagingToolSentTexts(),
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
messagingToolSentTargets: getMessagingToolSentTargets(),
heartbeatToolResponse: getHeartbeatToolResponse(),
toolMediaUrls: pendingToolMediaReply?.mediaUrls,
toolAudioAsVoice: pendingToolMediaReply?.audioAsVoice,
successfulCronAdds: getSuccessfulCronAdds(),

View File

@@ -1,5 +1,9 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
import {
createHeartbeatToolResponsePayload,
type HeartbeatToolResponse,
} from "../../../auto-reply/heartbeat-tool-response.js";
import { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
@@ -184,6 +188,7 @@ export function buildEmbeddedRunPayloads(params: {
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didSendDeterministicApprovalPrompt?: boolean;
heartbeatToolResponse?: HeartbeatToolResponse;
}): Array<{
text?: string;
mediaUrl?: string;
@@ -194,7 +199,12 @@ export function buildEmbeddedRunPayloads(params: {
audioAsVoice?: boolean;
replyToTag?: boolean;
replyToCurrent?: boolean;
channelData?: Record<string, unknown>;
}> {
if (params.heartbeatToolResponse) {
return [createHeartbeatToolResponsePayload(params.heartbeatToolResponse)];
}
const replyItems: Array<{
text: string;
media?: string[];

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { HeartbeatToolResponse } from "../../../auto-reply/heartbeat-tool-response.js";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../context-engine/types.js";
@@ -97,6 +98,7 @@ export type EmbeddedRunAttemptResult = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
heartbeatToolResponse?: HeartbeatToolResponse;
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;

View File

@@ -1,3 +1,4 @@
import type { HeartbeatToolResponse } from "../../auto-reply/heartbeat-tool-response.js";
import type { CliSessionBinding, SessionSystemPromptReport } from "../../config/sessions/types.js";
import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
import type { MessagingToolSend } from "../pi-embedded-messaging.types.js";
@@ -166,6 +167,7 @@ export type EmbeddedPiRunResult = {
isError?: boolean;
isReasoning?: boolean;
audioAsVoice?: boolean;
channelData?: Record<string, unknown>;
}>;
meta: EmbeddedPiRunMeta;
diagnosticTrace?: DiagnosticTraceContext;
@@ -178,6 +180,8 @@ export type EmbeddedPiRunResult = {
messagingToolSentMediaUrls?: string[];
// Messaging tool targets that successfully sent a message during the run.
messagingToolSentTargets?: MessagingToolSend[];
// Structured heartbeat outcome recorded by the heartbeat response tool.
heartbeatToolResponse?: HeartbeatToolResponse;
// Count of successful cron.add tool calls in this run.
successfulCronAdds?: number;
};

View File

@@ -1,4 +1,8 @@
import type { AgentEvent } from "@mariozechner/pi-agent-core";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
normalizeHeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import type {
AgentApprovalEventData,
AgentCommandOutputEventData,
@@ -904,6 +908,12 @@ export async function handleToolExecutionEnd(
if (!isToolError && toolName === "cron" && isCronAddAction(startData?.args)) {
ctx.state.successfulCronAdds += 1;
}
if (!isToolError && toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
const response = normalizeHeartbeatToolResponse(result?.details);
if (response) {
ctx.state.heartbeatToolResponse = response;
}
}
emitAgentEvent({
runId: ctx.params.runId,

View File

@@ -1,4 +1,5 @@
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { HeartbeatToolResponse } from "../auto-reply/heartbeat-tool-response.js";
import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
import type { ReasoningLevel } from "../auto-reply/thinking.js";
import type { InlineCodeState } from "../markdown/code-spans.js";
@@ -89,6 +90,7 @@ export type EmbeddedPiSubscribeState = {
messagingToolSentTexts: string[];
messagingToolSentTextsNormalized: string[];
messagingToolSentTargets: MessagingToolSend[];
heartbeatToolResponse?: HeartbeatToolResponse;
messagingToolSentMediaUrls: string[];
pendingMessagingTexts: Map<string, string>;
pendingMessagingTargets: Map<string, MessagingToolSend>;
@@ -207,6 +209,7 @@ export type ToolHandlerState = Pick<
| "messagingToolSentTextsNormalized"
| "messagingToolSentMediaUrls"
| "messagingToolSentTargets"
| "heartbeatToolResponse"
| "successfulCronAdds"
| "deterministicApprovalPromptSent"
>;

View File

@@ -170,6 +170,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
messagingToolSentTargets: [],
heartbeatToolResponse: undefined,
messagingToolSentMediaUrls: [],
pendingMessagingTexts: new Map(),
pendingMessagingTargets: new Map(),
@@ -989,6 +990,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
getMessagingToolSentMediaUrls: () => messagingToolSentMediaUrls.slice(),
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
getHeartbeatToolResponse: () =>
state.heartbeatToolResponse ? { ...state.heartbeatToolResponse } : undefined,
getPendingToolMediaReply: () => readPendingToolMediaReply(state),
getSuccessfulCronAdds: () => state.successfulCronAdds,
getReplayState: () => ({ ...state.replayState }),

View File

@@ -495,6 +495,29 @@ describe("createOpenClawCodingTools", () => {
expect(cronTools.some((tool) => tool.name === "message")).toBe(true);
});
it("keeps heartbeat response available for heartbeat runs under the coding profile", () => {
const codingTools = createOpenClawCodingTools({
config: { tools: { profile: "coding" } },
trigger: "heartbeat",
enableHeartbeatTool: true,
forceHeartbeatTool: true,
});
expect(codingTools.some((tool) => tool.name === "heartbeat_respond")).toBe(true);
});
it("enables heartbeat response when visible replies are message-tool-only", () => {
const tools = createOpenClawCodingTools({
config: {
messages: { visibleReplies: "message_tool" },
tools: { profile: "coding" },
} as OpenClawConfig,
trigger: "heartbeat",
});
expect(tools.some((tool) => tool.name === "heartbeat_respond")).toBe(true);
});
it("can keep message available when a cron route needs it under a provider coding profile", () => {
const providerProfileTools = createOpenClawCodingTools({
config: { tools: { byProvider: { openai: { profile: "coding" } } } },

View File

@@ -1,4 +1,5 @@
import { createCodingTools, createReadTool } from "@mariozechner/pi-coding-agent";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js";
import type { ModelCompatConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
@@ -347,6 +348,10 @@ export function createOpenClawCodingTools(options?: {
disableMessageTool?: boolean;
/** Keep the message tool available even when the selected profile omits it. */
forceMessageTool?: boolean;
/** Include the heartbeat response tool for structured heartbeat outcomes. */
enableHeartbeatTool?: boolean;
/** Keep the heartbeat response tool available even when the selected profile omits it. */
forceHeartbeatTool?: boolean;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
/**
@@ -408,7 +413,15 @@ export function createOpenClawCodingTools(options?: {
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const runtimeProfileAlsoAllow = options?.forceMessageTool ? ["message"] : [];
const enableHeartbeatTool =
options?.enableHeartbeatTool === true ||
(options?.trigger === "heartbeat" &&
options?.config?.messages?.visibleReplies === "message_tool");
const forceHeartbeatTool = options?.forceHeartbeatTool === true || enableHeartbeatTool;
const runtimeProfileAlsoAllow = [
...(options?.forceMessageTool ? ["message"] : []),
...(forceHeartbeatTool ? [HEARTBEAT_RESPONSE_TOOL_NAME] : []),
];
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, [
...(profileAlsoAllow ?? []),
...runtimeProfileAlsoAllow,
@@ -647,6 +660,7 @@ export function createOpenClawCodingTools(options?: {
modelHasVision: options?.modelHasVision,
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
disableMessageTool: options?.disableMessageTool,
enableHeartbeatTool,
...(cronSelfRemoveOnlyJobId ? { cronSelfRemoveOnlyJobId } : {}),
requesterAgentIdOverride: agentId,
requesterSenderId: options?.senderId,

View File

@@ -22,6 +22,7 @@ const coreTools = [
stubActionTool("nodes", ["list", "invoke"]),
stubActionTool("cron", ["schedule", "cancel"]),
stubActionTool("message", ["send", "reply"]),
stubTool("heartbeat_respond"),
stubActionTool("gateway", [
"restart",
"config.get",
@@ -46,7 +47,11 @@ const coreTools = [
stubTool("pdf"),
];
const createOpenClawToolsMock = vi.fn(() => coreTools.map((tool) => Object.assign({}, tool)));
const createOpenClawToolsMock = vi.fn((options?: { enableHeartbeatTool?: boolean }) =>
coreTools
.filter((tool) => tool.name !== "heartbeat_respond" || options?.enableHeartbeatTool === true)
.map((tool) => Object.assign({}, tool)),
);
vi.mock("../openclaw-tools.js", () => ({
createOpenClawTools: createOpenClawToolsMock,

View File

@@ -221,6 +221,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
profiles: ["messaging"],
includeInOpenClawGroup: true,
},
{
id: "heartbeat_respond",
label: "heartbeat_respond",
description: "Record heartbeat outcomes",
sectionId: "automation",
profiles: [],
includeInOpenClawGroup: true,
},
{
id: "cron",
label: "cron",

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../../auto-reply/heartbeat-tool-response.js";
import { createHeartbeatResponseTool } from "./heartbeat-response-tool.js";
function readSchemaProperty(schema: unknown, key: string): Record<string, unknown> {
const root = schema as { properties?: Record<string, unknown> };
const property = root.properties?.[key];
expect(property).toBeTruthy();
return property as Record<string, unknown>;
}
describe("createHeartbeatResponseTool", () => {
it("uses flat enum schemas for provider portability", () => {
const tool = createHeartbeatResponseTool();
const outcome = readSchemaProperty(tool.parameters, "outcome");
const priority = readSchemaProperty(tool.parameters, "priority");
expect(outcome).toMatchObject({
type: "string",
enum: ["no_change", "progress", "done", "blocked", "needs_attention"],
});
expect(priority).toMatchObject({
type: "string",
enum: ["low", "normal", "high"],
});
expect(outcome).not.toHaveProperty("anyOf");
expect(priority).not.toHaveProperty("anyOf");
});
it("records a quiet heartbeat outcome", async () => {
const tool = createHeartbeatResponseTool();
const result = await tool.execute("call-1", {
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
});
expect(tool.name).toBe(HEARTBEAT_RESPONSE_TOOL_NAME);
expect(result.details).toMatchObject({
status: "recorded",
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
});
});
it("accepts notification text and optional scheduling metadata", async () => {
const tool = createHeartbeatResponseTool();
const result = await tool.execute("call-1", {
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
nextCheck: "2026-05-01T17:00:00Z",
});
expect(result.details).toMatchObject({
status: "recorded",
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
nextCheck: "2026-05-01T17:00:00Z",
});
});
it("rejects missing notify because quiet vs visible delivery must be explicit", async () => {
const tool = createHeartbeatResponseTool();
await expect(
tool.execute("call-1", {
outcome: "no_change",
summary: "Nothing needs attention.",
}),
).rejects.toThrow("notify required");
});
});

View File

@@ -0,0 +1,63 @@
import { Type } from "typebox";
import {
HEARTBEAT_RESPONSE_TOOL_NAME,
HEARTBEAT_TOOL_OUTCOMES,
HEARTBEAT_TOOL_PRIORITIES,
normalizeHeartbeatToolResponse,
} from "../../auto-reply/heartbeat-tool-response.js";
import { readSnakeCaseParamRaw } from "../../param-key.js";
import { optionalStringEnum, stringEnum } from "../schema/string-enum.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, ToolInputError } from "./common.js";
const HeartbeatResponseToolSchema = Type.Object(
{
outcome: stringEnum(HEARTBEAT_TOOL_OUTCOMES),
notify: Type.Boolean(),
summary: Type.String(),
notificationText: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
priority: optionalStringEnum(HEARTBEAT_TOOL_PRIORITIES),
nextCheck: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function readRequiredBoolean(params: Record<string, unknown>, key: string): boolean {
const raw = readSnakeCaseParamRaw(params, key);
if (typeof raw !== "boolean") {
throw new ToolInputError(`${key} required`);
}
return raw;
}
export function createHeartbeatResponseTool(): AnyAgentTool {
return {
label: "Heartbeat",
name: HEARTBEAT_RESPONSE_TOOL_NAME,
displaySummary: "Record a heartbeat outcome and whether it should notify the user.",
description:
"Record the result of a heartbeat run. Use notify=false when nothing should be sent visibly. Use notify=true with notificationText when the user should receive a concise heartbeat alert.",
parameters: HeartbeatResponseToolSchema,
execute: async (_toolCallId, args) => {
if (!isRecord(args)) {
throw new ToolInputError("Heartbeat response arguments required");
}
readRequiredBoolean(args, "notify");
const response = normalizeHeartbeatToolResponse(args);
if (!response) {
throw new ToolInputError(
"Invalid heartbeat response. Provide outcome, notify, and non-empty summary.",
);
}
return jsonResult({
status: "recorded",
...response,
});
},
};
}

View File

@@ -0,0 +1,123 @@
import type { ReplyPayload } from "./reply-payload.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
export const HEARTBEAT_RESPONSE_TOOL_NAME = "heartbeat_respond";
export const HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY = "openclawHeartbeatResponse";
export const HEARTBEAT_TOOL_OUTCOMES = [
"no_change",
"progress",
"done",
"blocked",
"needs_attention",
] as const;
export type HeartbeatToolOutcome = (typeof HEARTBEAT_TOOL_OUTCOMES)[number];
export const HEARTBEAT_TOOL_PRIORITIES = ["low", "normal", "high"] as const;
export type HeartbeatToolPriority = (typeof HEARTBEAT_TOOL_PRIORITIES)[number];
export type HeartbeatToolResponse = {
outcome: HeartbeatToolOutcome;
notify: boolean;
summary: string;
notificationText?: string;
reason?: string;
priority?: HeartbeatToolPriority;
nextCheck?: string;
};
const OUTCOMES = new Set<string>(HEARTBEAT_TOOL_OUTCOMES);
const PRIORITIES = new Set<string>(HEARTBEAT_TOOL_PRIORITIES);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readStringAlias(record: Record<string, unknown>, ...keys: string[]) {
for (const key of keys) {
const value = readString(record[key]);
if (value) {
return value;
}
}
return undefined;
}
function readBooleanAlias(record: Record<string, unknown>, ...keys: string[]) {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
}
return undefined;
}
export function normalizeHeartbeatToolResponse(value: unknown): HeartbeatToolResponse | undefined {
if (!isRecord(value)) {
return undefined;
}
const outcome = readString(value.outcome);
const notify = readBooleanAlias(value, "notify");
const summary = readString(value.summary);
if (!outcome || !OUTCOMES.has(outcome) || notify === undefined || !summary) {
return undefined;
}
const priority = readString(value.priority);
const notificationText = readStringAlias(value, "notificationText", "notification_text");
const reason = readString(value.reason);
const nextCheck = readStringAlias(value, "nextCheck", "next_check");
return {
outcome: outcome as HeartbeatToolOutcome,
notify,
summary,
...(notificationText ? { notificationText } : {}),
...(reason ? { reason } : {}),
...(priority && PRIORITIES.has(priority)
? { priority: priority as HeartbeatToolPriority }
: {}),
...(nextCheck ? { nextCheck } : {}),
};
}
export function getHeartbeatToolNotificationText(response: HeartbeatToolResponse): string {
return response.notify ? (response.notificationText ?? response.summary).trim() : "";
}
export function createHeartbeatToolResponsePayload(response: HeartbeatToolResponse): ReplyPayload {
return {
text: response.notify ? getHeartbeatToolNotificationText(response) : HEARTBEAT_TOKEN,
channelData: {
[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY]: response,
},
};
}
export function getHeartbeatToolResponseFromPayload(
payload: ReplyPayload | undefined,
): HeartbeatToolResponse | undefined {
return normalizeHeartbeatToolResponse(
payload?.channelData?.[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY],
);
}
export function resolveHeartbeatToolResponseFromReplyResult(
replyResult: ReplyPayload | ReplyPayload[] | undefined,
): HeartbeatToolResponse | undefined {
if (!replyResult) {
return undefined;
}
const payloads = Array.isArray(replyResult) ? replyResult : [replyResult];
for (let idx = payloads.length - 1; idx >= 0; idx -= 1) {
const response = getHeartbeatToolResponseFromPayload(payloads[idx]);
if (response) {
return response;
}
}
return undefined;
}

View File

@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
clearApprovalNativeRouteStateForTest,
@@ -702,6 +703,7 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
clearAgentHarnesses();
const discordTestPlugin = {
...createChannelTestPluginBase({
id: "discord",
@@ -4410,6 +4412,42 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("uses harness defaults for direct source delivery when config is unset", async () => {
setNoAbort();
registerAgentHarness({
id: "codex",
label: "Codex",
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
supports: () => ({ supported: true, priority: 100 }),
runAttempt: vi.fn(async () => ({}) as never),
});
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
agentHarnessId: "codex",
sendPolicy: "allow",
};
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
return { text: "final reply" } satisfies ReplyPayload;
});
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
ChatType: "direct",
SessionKey: "agent:main:main",
}),
cfg: emptyConfig,
dispatcher,
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
setNoAbort();
const dispatcher = createDispatcher();

View File

@@ -5,6 +5,7 @@ import {
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { selectAgentHarness } from "../../agents/harness/selection.js";
import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
@@ -292,6 +293,42 @@ const createShouldEmitVerboseProgress = (params: {
return params.fallbackLevel !== "off";
};
};
const resolveHarnessSourceVisibleRepliesDefault = (params: {
cfg: OpenClawConfig;
ctx: FinalizedMsgContext;
entry?: SessionEntry;
sessionAgentId: string;
sessionKey?: string;
}): "automatic" | "message_tool" | undefined => {
if (params.ctx.CommandSource === "native") {
return undefined;
}
try {
const provider =
normalizeOptionalString(params.entry?.modelProvider) ??
normalizeOptionalString(params.ctx.Provider) ??
normalizeOptionalString(params.ctx.Surface) ??
"";
const harness = selectAgentHarness({
provider,
modelId: normalizeOptionalString(params.entry?.model),
config: params.cfg,
agentId: params.sessionAgentId,
sessionKey: params.sessionKey,
agentHarnessId:
normalizeOptionalString(params.entry?.agentHarnessId) ??
normalizeOptionalString(params.entry?.agentRuntimeOverride),
});
return harness.deliveryDefaults?.sourceVisibleReplies;
} catch (error) {
logVerbose(
`dispatch-from-config: could not resolve harness visible-reply defaults: ${formatErrorMessage(error)}`,
);
return undefined;
}
};
export type {
DispatchFromConfigParams,
DispatchFromConfigResult,
@@ -625,13 +662,24 @@ export async function dispatchReplyFromConfig(
chatType === "group" || chatType === "channel"
? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies)
: cfg.messages?.visibleReplies;
const harnessDefaultVisibleReplies =
configuredVisibleReplies === undefined && chatType !== "group" && chatType !== "channel"
? resolveHarnessSourceVisibleRepliesDefault({
cfg,
ctx,
entry: sessionStoreEntry.entry,
sessionAgentId,
sessionKey: acpDispatchSessionKey,
})
: undefined;
const effectiveVisibleReplies = configuredVisibleReplies ?? harnessDefaultVisibleReplies;
const prefersMessageToolDelivery =
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
ctx.CommandSource !== "native" &&
(chatType === "group" || chatType === "channel"
? configuredVisibleReplies !== "automatic"
: configuredVisibleReplies === "message_tool"));
? effectiveVisibleReplies !== "automatic"
: effectiveVisibleReplies === "message_tool"));
const runtimeProfileAlsoAllow = prefersMessageToolDelivery ? ["message"] : [];
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [
...(profileAlsoAllow ?? []),
@@ -690,6 +738,7 @@ export async function dispatchReplyFromConfig(
explicitSuppressTyping: params.replyOptions?.suppressTyping === true,
shouldSuppressTyping,
messageToolAvailable,
defaultVisibleReplies: harnessDefaultVisibleReplies,
});
const {
sourceReplyDeliveryMode,

View File

@@ -56,6 +56,23 @@ describe("resolveSourceReplyDeliveryMode", () => {
}
});
it("allows harnesses to default direct chats to message-tool-only delivery", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "direct" },
defaultVisibleReplies: "message_tool",
}),
).toBe("message_tool_only");
expect(
resolveSourceReplyDeliveryMode({
cfg: { messages: { visibleReplies: "automatic" } },
ctx: { ChatType: "direct" },
defaultVisibleReplies: "message_tool",
}),
).toBe("automatic");
});
it("lets group/channel config override the global visible reply mode", () => {
expect(
resolveSourceReplyDeliveryMode({

View File

@@ -13,6 +13,7 @@ export function resolveSourceReplyDeliveryMode(params: {
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
defaultVisibleReplies?: "automatic" | "message_tool";
}): SourceReplyDeliveryMode {
if (params.requested) {
return params.messageToolAvailable === false && params.requested === "message_tool_only"
@@ -29,8 +30,8 @@ export function resolveSourceReplyDeliveryMode(params: {
params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
} else {
mode =
params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
const configuredMode = params.cfg.messages?.visibleReplies ?? params.defaultVisibleReplies;
mode = configuredMode === "message_tool" ? "message_tool_only" : "automatic";
}
if (mode === "message_tool_only" && params.messageToolAvailable === false) {
return "automatic";
@@ -58,12 +59,14 @@ export function resolveSourceReplyVisibilityPolicy(params: {
explicitSuppressTyping?: boolean;
shouldSuppressTyping?: boolean;
messageToolAvailable?: boolean;
defaultVisibleReplies?: "automatic" | "message_tool";
}): SourceReplyVisibilityPolicy {
const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.ctx,
requested: params.requested,
messageToolAvailable: params.messageToolAvailable,
defaultVisibleReplies: params.defaultVisibleReplies,
});
const sendPolicyDenied = params.sendPolicy === "deny";
const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only";

View File

@@ -208,13 +208,18 @@ vi.mock("../infra/heartbeat-wake.js", async () => {
};
});
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
vi.mock("../logging/subsystem.js", () => {
const logger = {
info: mocks.logInfo,
warn: mocks.logWarn,
error: mocks.logError,
})),
}));
child: vi.fn(),
};
logger.child.mockReturnValue(logger);
return {
createSubsystemLogger: vi.fn(() => logger),
};
});
vi.mock("./server-methods/agent-timestamp.js", () => ({
injectTimestamp: mocks.injectTimestamp,

View File

@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import {
createHeartbeatToolResponsePayload,
type HeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import type { OpenClawConfig } from "../config/config.js";
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
import {
seedMainSessionStore,
withTempTelegramHeartbeatSandbox,
} from "./heartbeat-runner.test-utils.js";
installHeartbeatRunnerTestRuntime();
describe("runHeartbeatOnce heartbeat response tool", () => {
const TELEGRAM_GROUP = "-1001234567890";
function createConfig(params: { tmpDir: string; storePath: string }): OpenClawConfig {
return {
agents: {
defaults: {
workspace: params.tmpDir,
heartbeat: { every: "5m", target: "telegram" },
},
},
channels: {
telegram: {
token: "test-token",
allowFrom: ["*"],
heartbeat: { showOk: false },
},
},
session: { store: params.storePath },
} as OpenClawConfig;
}
function createDeps(params: {
sendTelegram: ReturnType<typeof vi.fn>;
getReplyFromConfig: HeartbeatDeps["getReplyFromConfig"];
}): HeartbeatDeps {
return {
telegram: params.sendTelegram as unknown,
getQueueSize: () => 0,
nowMs: () => 0,
getReplyFromConfig: params.getReplyFromConfig,
};
}
async function runWithToolResponse(response: HeartbeatToolResponse) {
return await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const cfg = createConfig({ tmpDir, storePath });
await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: TELEGRAM_GROUP,
});
replySpy.mockResolvedValue(createHeartbeatToolResponsePayload(response));
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1" });
const result = await runHeartbeatOnce({
cfg,
deps: createDeps({ sendTelegram, getReplyFromConfig: replySpy }),
});
return { result, sendTelegram, replySpy };
});
}
it("treats notify=false as a quiet heartbeat ack", async () => {
const { result, sendTelegram } = await runWithToolResponse({
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
});
expect(result.status).toBe("ran");
expect(sendTelegram).not.toHaveBeenCalled();
});
it("delivers notificationText when notify=true", async () => {
const { sendTelegram } = await runWithToolResponse({
outcome: "needs_attention",
notify: true,
summary: "Build is blocked.",
notificationText: "Build is blocked on missing credentials.",
priority: "high",
});
expect(sendTelegram).toHaveBeenCalledTimes(1);
expect(sendTelegram).toHaveBeenCalledWith(
TELEGRAM_GROUP,
"Build is blocked on missing credentials.",
expect.any(Object),
);
});
it("adds the heartbeat response tool hint to heartbeat prompts", async () => {
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const cfg = createConfig({ tmpDir, storePath });
await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: TELEGRAM_GROUP,
});
replySpy.mockResolvedValue(
createHeartbeatToolResponsePayload({
outcome: "no_change",
notify: false,
summary: "Nothing needs attention.",
}),
);
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1" });
await runHeartbeatOnce({
cfg,
deps: createDeps({ sendTelegram, getReplyFromConfig: replySpy }),
});
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string };
expect(calledCtx.Body).toContain("heartbeat_respond");
expect(calledCtx.Body).toContain("notify=false");
});
});
});

View File

@@ -16,6 +16,11 @@ import { isNestedAgentLane } from "../agents/lanes.js";
import { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js";
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
import {
getHeartbeatToolNotificationText,
resolveHeartbeatToolResponseFromReplyResult,
type HeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
isHeartbeatContentEffectivelyEmpty,
@@ -279,6 +284,15 @@ export function resolveHeartbeatPrompt(cfg: OpenClawConfig, heartbeat?: Heartbea
return resolveHeartbeatPromptText(heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt);
}
const HEARTBEAT_RESPONSE_TOOL_PROMPT =
"If the heartbeat_respond tool is available, call it to record the heartbeat outcome. Use notify=false for quiet/no-change outcomes. Use notify=true with notificationText for a concise user-visible alert.";
function appendHeartbeatResponseToolPrompt(prompt: string): string {
return prompt.includes("heartbeat_respond")
? prompt
: `${prompt}\n\n${HEARTBEAT_RESPONSE_TOOL_PROMPT}`;
}
function resolveHeartbeatAckMaxChars(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) {
return Math.max(
0,
@@ -580,6 +594,17 @@ function normalizeHeartbeatReply(
return { shouldSkip: false, text: finalText, hasMedia };
}
function normalizeHeartbeatToolNotification(
response: HeartbeatToolResponse,
responsePrefix: string | undefined,
) {
let finalText = getHeartbeatToolNotificationText(response);
if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
finalText = `${responsePrefix} ${finalText}`;
}
return { shouldSkip: false, text: finalText, hasMedia: false };
}
type HeartbeatReasonFlags = {
isExecEventReason: boolean;
isCronEventReason: boolean;
@@ -1221,8 +1246,9 @@ export async function runHeartbeatOnce(opts: {
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
};
const promptWithHeartbeatTool = appendHeartbeatResponseToolPrompt(prompt);
const ctx = {
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
Body: appendCronStyleCurrentTimeLine(promptWithHeartbeatTool, cfg, startedAt),
From: sender,
To: sender,
OriginatingChannel:
@@ -1344,13 +1370,45 @@ export async function runHeartbeatOnce(opts: {
const getReplyFromConfig =
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
const heartbeatToolResponse = resolveHeartbeatToolResponseFromReplyResult(replyResult);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
const includeReasoning = heartbeat?.includeReasoning === true;
const reasoningPayloads = includeReasoning
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
: [];
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const responsePrefix = resolveHeartbeatResponsePrefix();
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
if (heartbeatToolResponse && !heartbeatToolResponse.notify) {
await restoreHeartbeatUpdatedAt({
storePath,
sessionKey,
updatedAt: previousUpdatedAt,
});
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-token",
reason: opts.reason,
preview: heartbeatToolResponse.summary.slice(0, 200),
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
accountId: delivery.accountId,
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
await markCommitmentsStatus({
cfg,
ids: dueCommitmentIds,
status: "dismissed",
nowMs: startedAt,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
}
if (!heartbeatToolResponse && (!replyPayload || !hasOutboundReplyContent(replyPayload))) {
await restoreHeartbeatUpdatedAt({
storePath,
sessionKey,
@@ -1378,15 +1436,20 @@ export async function runHeartbeatOnce(opts: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const responsePrefix = resolveHeartbeatResponsePrefix();
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
const normalized = heartbeatToolResponse
? normalizeHeartbeatToolNotification(heartbeatToolResponse, responsePrefix)
: replyPayload
? normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars)
: { shouldSkip: true, text: "", hasMedia: false };
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
// The model should be responding with exec results, not ack tokens.
// Also, if normalized.text is empty due to token stripping but we have exec completion,
// fall back to the original reply text.
const execFallbackText =
hasRelayableExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
!heartbeatToolResponse &&
hasRelayableExecCompletion &&
!normalized.text.trim() &&
replyPayload?.text?.trim()
? replyPayload.text.trim()
: null;
if (execFallbackText) {
@@ -1423,7 +1486,10 @@ export async function runHeartbeatOnce(opts: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
const mediaUrls = resolveSendableOutboundReplyParts(replyPayload).mediaUrls;
const mediaUrls =
heartbeatToolResponse || !replyPayload
? []
: resolveSendableOutboundReplyParts(replyPayload).mediaUrls;
// Suppress duplicate heartbeats (same payload) within a short window.
// This prevents "nagging" when nothing changed but the model repeats the same items.

View File

@@ -16,6 +16,7 @@ export type {
AgentHarnessAttemptResult,
AgentHarnessCompactParams,
AgentHarnessCompactResult,
AgentHarnessDeliveryDefaults,
AgentHarnessResultClassification,
AgentHarnessResetParams,
AgentHarnessSupport,
@@ -30,6 +31,7 @@ export type { CompactEmbeddedPiSessionParams } from "../agents/pi-embedded-runne
export type { EmbeddedPiCompactResult } from "../agents/pi-embedded-runner/types.js";
export type { AnyAgentTool } from "../agents/tools/common.js";
export type { MessagingToolSend } from "../agents/pi-embedded-messaging.types.js";
export type { HeartbeatToolResponse } from "../auto-reply/heartbeat-tool-response.js";
export type { AgentApprovalEventData, AgentEventPayload } from "../infra/agent-events.js";
export type { ExecApprovalDecision } from "../infra/exec-approvals.js";
export type { NormalizedUsage } from "../agents/usage.js";
@@ -74,6 +76,10 @@ export {
selectDefaultNodeFromList,
} from "../agents/tools/nodes-utils.js";
export { formatToolAggregate } from "../auto-reply/tool-meta.js";
export {
HEARTBEAT_RESPONSE_TOOL_NAME,
normalizeHeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
export { isMessagingTool, isMessagingToolSendAction } from "../agents/pi-embedded-messaging.js";
export {
extractToolResultMediaArtifact,