mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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?(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function createSubscriptionMock(): SubscriptionMock {
|
||||
getMessagingToolSentTexts: () => [] as string[],
|
||||
getMessagingToolSentMediaUrls: () => [] as string[],
|
||||
getMessagingToolSentTargets: () => [] as MessagingToolSend[],
|
||||
getHeartbeatToolResponse: () => undefined,
|
||||
getPendingToolMediaReply: () => null,
|
||||
getSuccessfulCronAdds: () => 0,
|
||||
getReplayState: () => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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" } } } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
82
src/agents/tools/heartbeat-response-tool.test.ts
Normal file
82
src/agents/tools/heartbeat-response-tool.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
63
src/agents/tools/heartbeat-response-tool.ts
Normal file
63
src/agents/tools/heartbeat-response-tool.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
123
src/auto-reply/heartbeat-tool-response.ts
Normal file
123
src/auto-reply/heartbeat-tool-response.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
125
src/infra/heartbeat-runner.tool-response.test.ts
Normal file
125
src/infra/heartbeat-runner.tool-response.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user