From 439d8edf68e2b650bc47ecb7edc82ccffbe74570 Mon Sep 17 00:00:00 2001 From: pashpashpash Date: Fri, 1 May 2026 11:30:41 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/groups.md | 2 +- docs/gateway/config-channels.md | 6 +- docs/gateway/heartbeat.md | 1 + docs/plugins/codex-harness.md | 14 +- extensions/codex/harness.ts | 3 + extensions/codex/index.test.ts | 2 + .../src/app-server/dynamic-tools.test.ts | 37 +++++- .../codex/src/app-server/dynamic-tools.ts | 11 ++ .../codex/src/app-server/event-projector.ts | 3 + .../codex/src/app-server/run-attempt.test.ts | 2 + .../codex/src/app-server/run-attempt.ts | 2 + src/agents/harness/types.ts | 9 ++ src/agents/openclaw-tools.ts | 5 + src/agents/pi-embedded-runner/run.ts | 6 + .../attempt.spawn-workspace.test-support.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/payloads.ts | 10 ++ src/agents/pi-embedded-runner/run/types.ts | 2 + src/agents/pi-embedded-runner/types.ts | 4 + .../pi-embedded-subscribe.handlers.tools.ts | 10 ++ .../pi-embedded-subscribe.handlers.types.ts | 3 + src/agents/pi-embedded-subscribe.ts | 3 + ...tools.create-openclaw-coding-tools.test.ts | 23 ++++ src/agents/pi-tools.ts | 16 ++- .../test-helpers/fast-openclaw-tools.ts | 7 +- src/agents/tool-catalog.ts | 8 ++ .../tools/heartbeat-response-tool.test.ts | 82 ++++++++++++ src/agents/tools/heartbeat-response-tool.ts | 63 +++++++++ src/auto-reply/heartbeat-tool-response.ts | 123 +++++++++++++++++ .../reply/dispatch-from-config.test.ts | 38 ++++++ src/auto-reply/reply/dispatch-from-config.ts | 53 +++++++- .../reply/source-reply-delivery-mode.test.ts | 17 +++ .../reply/source-reply-delivery-mode.ts | 7 +- src/gateway/server-restart-sentinel.test.ts | 13 +- .../heartbeat-runner.tool-response.test.ts | 125 ++++++++++++++++++ src/infra/heartbeat-runner.ts | 80 ++++++++++- src/plugin-sdk/agent-harness-runtime.ts | 6 + 39 files changed, 780 insertions(+), 25 deletions(-) create mode 100644 src/agents/tools/heartbeat-response-tool.test.ts create mode 100644 src/agents/tools/heartbeat-response-tool.ts create mode 100644 src/auto-reply/heartbeat-tool-response.ts create mode 100644 src/infra/heartbeat-runner.tool-response.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d23062bfd..4fb75725f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`) 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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index c03babba30d..9c6953fd030 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 37a234aa26e..2e65f573f04 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -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. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index b551d8107bd..11ef585e180 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -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..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 diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index b7533e2de57..bd357f1adf7 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -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. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 27daed56e81..d7175aadbc4 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -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: diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index c1074979166..008727618c4 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -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)) { diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 9664a876e72..989dbbf3e41 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -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, diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 0a9441a5995..f15d21a96ff 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -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( diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 050fcc2963b..58953391fea 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -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): boolean { status !== "ok" && status !== "success" && status !== "completed" && + status !== "recorded" && status !== "running" ); } diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 149274ad99a..ace1519135b 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -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, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index f531427c2f9..c4536c08b5a 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -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", ]); }); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 35009a31924..5f03573c22a 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -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, { diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 247a93c18fd..08a7f7b1061 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -27,10 +27,19 @@ export type AgentHarnessResultClassification = | "ok" | NonNullable; +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; classify?( diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index ea52c249ce4..2a366d1f6a4 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 5bc75e1972d..a1ac66a64cf 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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, }; } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index cd8d3877040..d5b3ab6ac67 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -94,6 +94,7 @@ export function createSubscriptionMock(): SubscriptionMock { getMessagingToolSentTexts: () => [] as string[], getMessagingToolSentMediaUrls: () => [] as string[], getMessagingToolSentTargets: () => [] as MessagingToolSend[], + getHeartbeatToolResponse: () => undefined, getPendingToolMediaReply: () => null, getSuccessfulCronAdds: () => 0, getReplayState: () => ({ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 5548b319b5c..e8fa493b713 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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(), diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 693c41508bc..2499d83b9cd 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -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; }> { + if (params.heartbeatToolResponse) { + return [createHeartbeatToolResponsePayload(params.heartbeatToolResponse)]; + } + const replyItems: Array<{ text: string; media?: string[]; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 6f75d152bb6..9f6e80bb530 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 6ca96dab6ed..924009667fa 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -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; }>; 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; }; diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 893533803e0..cf16a708815 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -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, diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 2b93baa8ab2..5497907d713 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -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; pendingMessagingTargets: Map; @@ -207,6 +209,7 @@ export type ToolHandlerState = Pick< | "messagingToolSentTextsNormalized" | "messagingToolSentMediaUrls" | "messagingToolSentTargets" + | "heartbeatToolResponse" | "successfulCronAdds" | "deterministicApprovalPromptSent" >; diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 6b7833227c0..96dc2f2bb30 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -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 }), diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index 78960d20f84..a00a707a6de 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -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" } } } }, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 4ab0bcf6164..0d09f5ebd3a 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, diff --git a/src/agents/test-helpers/fast-openclaw-tools.ts b/src/agents/test-helpers/fast-openclaw-tools.ts index fad86a4c05b..30bd14aef87 100644 --- a/src/agents/test-helpers/fast-openclaw-tools.ts +++ b/src/agents/test-helpers/fast-openclaw-tools.ts @@ -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, diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 483eb19fea8..7dd0ed6ab27 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -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", diff --git a/src/agents/tools/heartbeat-response-tool.test.ts b/src/agents/tools/heartbeat-response-tool.test.ts new file mode 100644 index 00000000000..338e5a4e53b --- /dev/null +++ b/src/agents/tools/heartbeat-response-tool.test.ts @@ -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 { + const root = schema as { properties?: Record }; + const property = root.properties?.[key]; + expect(property).toBeTruthy(); + return property as Record; +} + +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"); + }); +}); diff --git a/src/agents/tools/heartbeat-response-tool.ts b/src/agents/tools/heartbeat-response-tool.ts new file mode 100644 index 00000000000..859142e7fcd --- /dev/null +++ b/src/agents/tools/heartbeat-response-tool.ts @@ -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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function readRequiredBoolean(params: Record, 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, + }); + }, + }; +} diff --git a/src/auto-reply/heartbeat-tool-response.ts b/src/auto-reply/heartbeat-tool-response.ts new file mode 100644 index 00000000000..9cc9eda3603 --- /dev/null +++ b/src/auto-reply/heartbeat-tool-response.ts @@ -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(HEARTBEAT_TOOL_OUTCOMES); +const PRIORITIES = new Set(HEARTBEAT_TOOL_PRIORITIES); + +function isRecord(value: unknown): value is Record { + 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, ...keys: string[]) { + for (const key of keys) { + const value = readString(record[key]); + if (value) { + return value; + } + } + return undefined; +} + +function readBooleanAlias(record: Record, ...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; +} diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 5d82dfa6026..7558da0eb4e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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 { 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(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index f610f7919c0..e7f6de6f9f9 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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, diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index d5e0cfc3d83..13ed7cecdd1 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -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({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 68bdd3649e2..75803ccf38f 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -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"; diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index f5cc8ff1980..5a75cb6f6d5 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -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, diff --git a/src/infra/heartbeat-runner.tool-response.test.ts b/src/infra/heartbeat-runner.tool-response.test.ts new file mode 100644 index 00000000000..4c301a35a45 --- /dev/null +++ b/src/infra/heartbeat-runner.tool-response.test.ts @@ -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; + 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"); + }); + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 75c09c0ef47..04e8c7a5ff9 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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. diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 8202ebd73a7..60ac91280fb 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -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,