mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:00:42 +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.
|
- 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.
|
- 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 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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
37787172adf7a55a32097599b4bf5729fc7138c8743c6f4c9d58fc8d01df72a1 plugin-sdk-api-baseline.json
|
42cb8c8be1e10a42891035fc402022ab1cc2eb941e7e69a9a2f8a6d01a30bd3e plugin-sdk-api-baseline.json
|
||||||
0ec4957528477832085c638a5f7f691c878ba199f3e81f330f162c27cfd9ebf4 plugin-sdk-api-baseline.jsonl
|
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.
|
back to automatic visible replies instead of silently suppressing the response.
|
||||||
`openclaw doctor` warns about this mismatch.
|
`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.
|
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.
|
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.
|
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
|
```json5
|
||||||
{
|
{
|
||||||
messages: {
|
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: {
|
groupChat: {
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
visibleReplies: "message_tool", // default; use "automatic" for legacy final replies
|
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.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
|
#### DM history limits
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub
|
|||||||
## Response contract
|
## Response contract
|
||||||
|
|
||||||
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
|
- 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).
|
- 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.
|
- 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.
|
- 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,
|
OpenClaw still owns chat channels, session files, model selection, tools,
|
||||||
approvals, media delivery, and the visible transcript mirror.
|
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
|
If you are trying to orient yourself, start with
|
||||||
[Agent runtimes](/concepts/agent-runtimes). The short version is:
|
[Agent runtimes](/concepts/agent-runtimes). The short version is:
|
||||||
`openai/gpt-5.5` is the model ref, `codex` is the runtime, and Telegram,
|
`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
|
OpenClaw does not expose dynamic tools that duplicate Codex-native workspace
|
||||||
operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and
|
operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and
|
||||||
`update_plan`. OpenClaw integration tools such as messaging, sessions, media,
|
`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:
|
Supported top-level Codex plugin fields:
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function createCodexAppServerAgentHarness(options?: {
|
|||||||
return {
|
return {
|
||||||
id: options?.id ?? "codex",
|
id: options?.id ?? "codex",
|
||||||
label: options?.label ?? "Codex agent harness",
|
label: options?.label ?? "Codex agent harness",
|
||||||
|
deliveryDefaults: {
|
||||||
|
sourceVisibleReplies: "message_tool",
|
||||||
|
},
|
||||||
supports: (ctx) => {
|
supports: (ctx) => {
|
||||||
const provider = ctx.provider.trim().toLowerCase();
|
const provider = ctx.provider.trim().toLowerCase();
|
||||||
if (providerIds.has(provider)) {
|
if (providerIds.has(provider)) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe("codex plugin", () => {
|
|||||||
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
|
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex agent harness",
|
label: "Codex agent harness",
|
||||||
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
||||||
dispose: expect.any(Function),
|
dispose: expect.any(Function),
|
||||||
});
|
});
|
||||||
expect(registerMediaUnderstandingProvider.mock.calls[0]?.[0]).toMatchObject({
|
expect(registerMediaUnderstandingProvider.mock.calls[0]?.[0]).toMatchObject({
|
||||||
@@ -89,6 +90,7 @@ describe("codex plugin", () => {
|
|||||||
it("only claims the codex provider by default", () => {
|
it("only claims the codex provider by default", () => {
|
||||||
const harness = createCodexAppServerAgentHarness();
|
const harness = createCodexAppServerAgentHarness();
|
||||||
|
|
||||||
|
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
|
||||||
expect(
|
expect(
|
||||||
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
|
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
|
||||||
.supported,
|
.supported,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
|
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 {
|
import {
|
||||||
initializeGlobalHookRunner,
|
initializeGlobalHookRunner,
|
||||||
resetGlobalHookRunner,
|
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 () => {
|
it("applies agent tool result middleware from the active plugin registry", async () => {
|
||||||
const registry = createEmptyPluginRegistry();
|
const registry = createEmptyPluginRegistry();
|
||||||
const handler = vi.fn(
|
const handler = vi.fn(
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
createCodexAppServerToolResultExtensionRunner,
|
createCodexAppServerToolResultExtensionRunner,
|
||||||
extractToolResultMediaArtifact,
|
extractToolResultMediaArtifact,
|
||||||
filterToolResultMediaUrls,
|
filterToolResultMediaUrls,
|
||||||
|
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||||
isToolWrappedWithBeforeToolCallHook,
|
isToolWrappedWithBeforeToolCallHook,
|
||||||
isMessagingTool,
|
isMessagingTool,
|
||||||
isMessagingToolSendAction,
|
isMessagingToolSendAction,
|
||||||
|
normalizeHeartbeatToolResponse,
|
||||||
runAgentHarnessAfterToolCallHook,
|
runAgentHarnessAfterToolCallHook,
|
||||||
type AnyAgentTool,
|
type AnyAgentTool,
|
||||||
|
type HeartbeatToolResponse,
|
||||||
type MessagingToolSend,
|
type MessagingToolSend,
|
||||||
wrapToolWithBeforeToolCallHook,
|
wrapToolWithBeforeToolCallHook,
|
||||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||||
@@ -32,6 +35,7 @@ export type CodexDynamicToolBridge = {
|
|||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentMediaUrls: string[];
|
messagingToolSentMediaUrls: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
|
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||||
toolMediaUrls: string[];
|
toolMediaUrls: string[];
|
||||||
toolAudioAsVoice: boolean;
|
toolAudioAsVoice: boolean;
|
||||||
successfulCronAdds?: number;
|
successfulCronAdds?: number;
|
||||||
@@ -190,6 +194,12 @@ function collectToolTelemetry(params: {
|
|||||||
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
|
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
|
||||||
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
|
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) {
|
if (!params.isError && params.result) {
|
||||||
const media = extractToolResultMediaArtifact(params.result);
|
const media = extractToolResultMediaArtifact(params.result);
|
||||||
if (media) {
|
if (media) {
|
||||||
@@ -256,6 +266,7 @@ function isToolResultError(result: AgentToolResult<unknown>): boolean {
|
|||||||
status !== "ok" &&
|
status !== "ok" &&
|
||||||
status !== "success" &&
|
status !== "success" &&
|
||||||
status !== "completed" &&
|
status !== "completed" &&
|
||||||
|
status !== "recorded" &&
|
||||||
status !== "running"
|
status !== "running"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type AgentMessage,
|
type AgentMessage,
|
||||||
type EmbeddedRunAttemptParams,
|
type EmbeddedRunAttemptParams,
|
||||||
type EmbeddedRunAttemptResult,
|
type EmbeddedRunAttemptResult,
|
||||||
|
type HeartbeatToolResponse,
|
||||||
type MessagingToolSend,
|
type MessagingToolSend,
|
||||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||||
import { readCodexTurn } from "./protocol-validators.js";
|
import { readCodexTurn } from "./protocol-validators.js";
|
||||||
@@ -32,6 +33,7 @@ export type CodexAppServerToolTelemetry = {
|
|||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentMediaUrls: string[];
|
messagingToolSentMediaUrls: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
|
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||||
toolMediaUrls?: string[];
|
toolMediaUrls?: string[];
|
||||||
toolAudioAsVoice?: boolean;
|
toolAudioAsVoice?: boolean;
|
||||||
successfulCronAdds?: number;
|
successfulCronAdds?: number;
|
||||||
@@ -232,6 +234,7 @@ export class CodexAppServerEventProjector {
|
|||||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||||
|
heartbeatToolResponse: toolTelemetry.heartbeatToolResponse,
|
||||||
toolMediaUrls: toolTelemetry.toolMediaUrls,
|
toolMediaUrls: toolTelemetry.toolMediaUrls,
|
||||||
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
|
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
|
||||||
successfulCronAdds: toolTelemetry.successfulCronAdds,
|
successfulCronAdds: toolTelemetry.successfulCronAdds,
|
||||||
|
|||||||
@@ -360,12 +360,14 @@ describe("runCodexAppServerAttempt", () => {
|
|||||||
"update_plan",
|
"update_plan",
|
||||||
"web_search",
|
"web_search",
|
||||||
"message",
|
"message",
|
||||||
|
"heartbeat_respond",
|
||||||
"sessions_spawn",
|
"sessions_spawn",
|
||||||
].map((name) => ({ name }));
|
].map((name) => ({ name }));
|
||||||
|
|
||||||
expect(__testing.applyCodexDynamicToolProfile(tools, {}).map((tool) => tool.name)).toEqual([
|
expect(__testing.applyCodexDynamicToolProfile(tools, {}).map((tool) => tool.name)).toEqual([
|
||||||
"web_search",
|
"web_search",
|
||||||
"message",
|
"message",
|
||||||
|
"heartbeat_respond",
|
||||||
"sessions_spawn",
|
"sessions_spawn",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1398,6 +1398,8 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
|||||||
requireExplicitMessageTarget:
|
requireExplicitMessageTarget:
|
||||||
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
||||||
disableMessageTool: params.disableMessageTool,
|
disableMessageTool: params.disableMessageTool,
|
||||||
|
enableHeartbeatTool: params.trigger === "heartbeat",
|
||||||
|
forceHeartbeatTool: params.trigger === "heartbeat",
|
||||||
onYield: (message) => {
|
onYield: (message) => {
|
||||||
input.onYieldDetected();
|
input.onYieldDetected();
|
||||||
emitCodexAppServerEvent(params, {
|
emitCodexAppServerEvent(params, {
|
||||||
|
|||||||
@@ -27,10 +27,19 @@ export type AgentHarnessResultClassification =
|
|||||||
| "ok"
|
| "ok"
|
||||||
| NonNullable<AgentHarnessAttemptResult["agentHarnessResultClassification"]>;
|
| 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 = {
|
export type AgentHarness = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
deliveryDefaults?: AgentHarnessDeliveryDefaults;
|
||||||
supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport;
|
supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport;
|
||||||
runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>;
|
runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>;
|
||||||
classify?(
|
classify?(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { AnyAgentTool } from "./tools/common.js";
|
|||||||
import { createCronTool } from "./tools/cron-tool.js";
|
import { createCronTool } from "./tools/cron-tool.js";
|
||||||
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
|
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
|
||||||
import { createGatewayTool } from "./tools/gateway-tool.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 { createImageGenerateTool } from "./tools/image-generate-tool.js";
|
||||||
import { createImageTool } from "./tools/image-tool.js";
|
import { createImageTool } from "./tools/image-tool.js";
|
||||||
import { createMessageTool } from "./tools/message-tool.js";
|
import { createMessageTool } from "./tools/message-tool.js";
|
||||||
@@ -95,6 +96,8 @@ export function createOpenClawTools(
|
|||||||
requireExplicitMessageTarget?: boolean;
|
requireExplicitMessageTarget?: boolean;
|
||||||
/** If true, omit the message tool from the tool list. */
|
/** If true, omit the message tool from the tool list. */
|
||||||
disableMessageTool?: boolean;
|
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. */
|
/** If true, skip plugin tool resolution and return only shipped core tools. */
|
||||||
disablePluginTools?: boolean;
|
disablePluginTools?: boolean;
|
||||||
/** Trusted sender id from inbound context (not tool args). */
|
/** Trusted sender id from inbound context (not tool args). */
|
||||||
@@ -215,6 +218,7 @@ export function createOpenClawTools(
|
|||||||
requesterSenderId: options?.requesterSenderId ?? undefined,
|
requesterSenderId: options?.requesterSenderId ?? undefined,
|
||||||
senderIsOwner: options?.senderIsOwner,
|
senderIsOwner: options?.senderIsOwner,
|
||||||
});
|
});
|
||||||
|
const heartbeatTool = options?.enableHeartbeatTool ? createHeartbeatResponseTool() : null;
|
||||||
const nodesToolBase = createNodesTool({
|
const nodesToolBase = createNodesTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
agentChannel: options?.agentChannel,
|
agentChannel: options?.agentChannel,
|
||||||
@@ -255,6 +259,7 @@ export function createOpenClawTools(
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
...(!embedded && messageTool ? [messageTool] : []),
|
...(!embedded && messageTool ? [messageTool] : []),
|
||||||
|
...collectPresentOpenClawTools([heartbeatTool]),
|
||||||
createTtsTool({
|
createTtsTool({
|
||||||
agentChannel: options?.agentChannel,
|
agentChannel: options?.agentChannel,
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
|
|||||||
@@ -2058,6 +2058,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
inlineToolResultsAllowed: false,
|
inlineToolResultsAllowed: false,
|
||||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||||
|
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||||
});
|
});
|
||||||
const payloadsWithToolMedia = mergeAttemptToolMediaPayloads({
|
const payloadsWithToolMedia = mergeAttemptToolMediaPayloads({
|
||||||
payloads,
|
payloads,
|
||||||
@@ -2120,6 +2121,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||||
|
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||||
successfulCronAdds: attempt.successfulCronAdds,
|
successfulCronAdds: attempt.successfulCronAdds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2312,6 +2314,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||||
|
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||||
successfulCronAdds: attempt.successfulCronAdds,
|
successfulCronAdds: attempt.successfulCronAdds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2362,6 +2365,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||||
|
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||||
successfulCronAdds: attempt.successfulCronAdds,
|
successfulCronAdds: attempt.successfulCronAdds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2471,6 +2475,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||||
|
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||||
successfulCronAdds: attempt.successfulCronAdds,
|
successfulCronAdds: attempt.successfulCronAdds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2590,6 +2595,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||||
|
heartbeatToolResponse: attempt.heartbeatToolResponse,
|
||||||
successfulCronAdds: attempt.successfulCronAdds,
|
successfulCronAdds: attempt.successfulCronAdds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function createSubscriptionMock(): SubscriptionMock {
|
|||||||
getMessagingToolSentTexts: () => [] as string[],
|
getMessagingToolSentTexts: () => [] as string[],
|
||||||
getMessagingToolSentMediaUrls: () => [] as string[],
|
getMessagingToolSentMediaUrls: () => [] as string[],
|
||||||
getMessagingToolSentTargets: () => [] as MessagingToolSend[],
|
getMessagingToolSentTargets: () => [] as MessagingToolSend[],
|
||||||
|
getHeartbeatToolResponse: () => undefined,
|
||||||
getPendingToolMediaReply: () => null,
|
getPendingToolMediaReply: () => null,
|
||||||
getSuccessfulCronAdds: () => 0,
|
getSuccessfulCronAdds: () => 0,
|
||||||
getReplayState: () => ({
|
getReplayState: () => ({
|
||||||
|
|||||||
@@ -2217,6 +2217,7 @@ export async function runEmbeddedAttempt(
|
|||||||
getMessagingToolSentTexts,
|
getMessagingToolSentTexts,
|
||||||
getMessagingToolSentMediaUrls,
|
getMessagingToolSentMediaUrls,
|
||||||
getMessagingToolSentTargets,
|
getMessagingToolSentTargets,
|
||||||
|
getHeartbeatToolResponse,
|
||||||
getPendingToolMediaReply,
|
getPendingToolMediaReply,
|
||||||
getSuccessfulCronAdds,
|
getSuccessfulCronAdds,
|
||||||
getReplayState,
|
getReplayState,
|
||||||
@@ -3437,6 +3438,7 @@ export async function runEmbeddedAttempt(
|
|||||||
messagingToolSentTexts: getMessagingToolSentTexts(),
|
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||||
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
|
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
|
||||||
messagingToolSentTargets: getMessagingToolSentTargets(),
|
messagingToolSentTargets: getMessagingToolSentTargets(),
|
||||||
|
heartbeatToolResponse: getHeartbeatToolResponse(),
|
||||||
toolMediaUrls: pendingToolMediaReply?.mediaUrls,
|
toolMediaUrls: pendingToolMediaReply?.mediaUrls,
|
||||||
toolAudioAsVoice: pendingToolMediaReply?.audioAsVoice,
|
toolAudioAsVoice: pendingToolMediaReply?.audioAsVoice,
|
||||||
successfulCronAdds: getSuccessfulCronAdds(),
|
successfulCronAdds: getSuccessfulCronAdds(),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
|
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 { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js";
|
||||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
||||||
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
|
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
|
||||||
@@ -184,6 +188,7 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
inlineToolResultsAllowed: boolean;
|
inlineToolResultsAllowed: boolean;
|
||||||
didSendViaMessagingTool?: boolean;
|
didSendViaMessagingTool?: boolean;
|
||||||
didSendDeterministicApprovalPrompt?: boolean;
|
didSendDeterministicApprovalPrompt?: boolean;
|
||||||
|
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||||
}): Array<{
|
}): Array<{
|
||||||
text?: string;
|
text?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
@@ -194,7 +199,12 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
replyToTag?: boolean;
|
replyToTag?: boolean;
|
||||||
replyToCurrent?: boolean;
|
replyToCurrent?: boolean;
|
||||||
|
channelData?: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
|
if (params.heartbeatToolResponse) {
|
||||||
|
return [createHeartbeatToolResponsePayload(params.heartbeatToolResponse)];
|
||||||
|
}
|
||||||
|
|
||||||
const replyItems: Array<{
|
const replyItems: Array<{
|
||||||
text: string;
|
text: string;
|
||||||
media?: string[];
|
media?: string[];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||||
import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
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 { ThinkLevel } from "../../../auto-reply/thinking.js";
|
||||||
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
|
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
|
||||||
import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../context-engine/types.js";
|
import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../context-engine/types.js";
|
||||||
@@ -97,6 +98,7 @@ export type EmbeddedRunAttemptResult = {
|
|||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentMediaUrls: string[];
|
messagingToolSentMediaUrls: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
|
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||||
toolMediaUrls?: string[];
|
toolMediaUrls?: string[];
|
||||||
toolAudioAsVoice?: boolean;
|
toolAudioAsVoice?: boolean;
|
||||||
successfulCronAdds?: number;
|
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 { CliSessionBinding, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||||
import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
|
import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js";
|
||||||
import type { MessagingToolSend } from "../pi-embedded-messaging.types.js";
|
import type { MessagingToolSend } from "../pi-embedded-messaging.types.js";
|
||||||
@@ -166,6 +167,7 @@ export type EmbeddedPiRunResult = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
isReasoning?: boolean;
|
isReasoning?: boolean;
|
||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
|
channelData?: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
meta: EmbeddedPiRunMeta;
|
meta: EmbeddedPiRunMeta;
|
||||||
diagnosticTrace?: DiagnosticTraceContext;
|
diagnosticTrace?: DiagnosticTraceContext;
|
||||||
@@ -178,6 +180,8 @@ export type EmbeddedPiRunResult = {
|
|||||||
messagingToolSentMediaUrls?: string[];
|
messagingToolSentMediaUrls?: string[];
|
||||||
// Messaging tool targets that successfully sent a message during the run.
|
// Messaging tool targets that successfully sent a message during the run.
|
||||||
messagingToolSentTargets?: MessagingToolSend[];
|
messagingToolSentTargets?: MessagingToolSend[];
|
||||||
|
// Structured heartbeat outcome recorded by the heartbeat response tool.
|
||||||
|
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||||
// Count of successful cron.add tool calls in this run.
|
// Count of successful cron.add tool calls in this run.
|
||||||
successfulCronAdds?: number;
|
successfulCronAdds?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||||
|
import {
|
||||||
|
HEARTBEAT_RESPONSE_TOOL_NAME,
|
||||||
|
normalizeHeartbeatToolResponse,
|
||||||
|
} from "../auto-reply/heartbeat-tool-response.js";
|
||||||
import type {
|
import type {
|
||||||
AgentApprovalEventData,
|
AgentApprovalEventData,
|
||||||
AgentCommandOutputEventData,
|
AgentCommandOutputEventData,
|
||||||
@@ -904,6 +908,12 @@ export async function handleToolExecutionEnd(
|
|||||||
if (!isToolError && toolName === "cron" && isCronAddAction(startData?.args)) {
|
if (!isToolError && toolName === "cron" && isCronAddAction(startData?.args)) {
|
||||||
ctx.state.successfulCronAdds += 1;
|
ctx.state.successfulCronAdds += 1;
|
||||||
}
|
}
|
||||||
|
if (!isToolError && toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
|
||||||
|
const response = normalizeHeartbeatToolResponse(result?.details);
|
||||||
|
if (response) {
|
||||||
|
ctx.state.heartbeatToolResponse = response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: ctx.params.runId,
|
runId: ctx.params.runId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
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 { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-directives.js";
|
||||||
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
import type { ReasoningLevel } from "../auto-reply/thinking.js";
|
||||||
import type { InlineCodeState } from "../markdown/code-spans.js";
|
import type { InlineCodeState } from "../markdown/code-spans.js";
|
||||||
@@ -89,6 +90,7 @@ export type EmbeddedPiSubscribeState = {
|
|||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentTextsNormalized: string[];
|
messagingToolSentTextsNormalized: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
|
heartbeatToolResponse?: HeartbeatToolResponse;
|
||||||
messagingToolSentMediaUrls: string[];
|
messagingToolSentMediaUrls: string[];
|
||||||
pendingMessagingTexts: Map<string, string>;
|
pendingMessagingTexts: Map<string, string>;
|
||||||
pendingMessagingTargets: Map<string, MessagingToolSend>;
|
pendingMessagingTargets: Map<string, MessagingToolSend>;
|
||||||
@@ -207,6 +209,7 @@ export type ToolHandlerState = Pick<
|
|||||||
| "messagingToolSentTextsNormalized"
|
| "messagingToolSentTextsNormalized"
|
||||||
| "messagingToolSentMediaUrls"
|
| "messagingToolSentMediaUrls"
|
||||||
| "messagingToolSentTargets"
|
| "messagingToolSentTargets"
|
||||||
|
| "heartbeatToolResponse"
|
||||||
| "successfulCronAdds"
|
| "successfulCronAdds"
|
||||||
| "deterministicApprovalPromptSent"
|
| "deterministicApprovalPromptSent"
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
messagingToolSentTexts: [],
|
messagingToolSentTexts: [],
|
||||||
messagingToolSentTextsNormalized: [],
|
messagingToolSentTextsNormalized: [],
|
||||||
messagingToolSentTargets: [],
|
messagingToolSentTargets: [],
|
||||||
|
heartbeatToolResponse: undefined,
|
||||||
messagingToolSentMediaUrls: [],
|
messagingToolSentMediaUrls: [],
|
||||||
pendingMessagingTexts: new Map(),
|
pendingMessagingTexts: new Map(),
|
||||||
pendingMessagingTargets: new Map(),
|
pendingMessagingTargets: new Map(),
|
||||||
@@ -989,6 +990,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
|
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
|
||||||
getMessagingToolSentMediaUrls: () => messagingToolSentMediaUrls.slice(),
|
getMessagingToolSentMediaUrls: () => messagingToolSentMediaUrls.slice(),
|
||||||
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
|
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
|
||||||
|
getHeartbeatToolResponse: () =>
|
||||||
|
state.heartbeatToolResponse ? { ...state.heartbeatToolResponse } : undefined,
|
||||||
getPendingToolMediaReply: () => readPendingToolMediaReply(state),
|
getPendingToolMediaReply: () => readPendingToolMediaReply(state),
|
||||||
getSuccessfulCronAdds: () => state.successfulCronAdds,
|
getSuccessfulCronAdds: () => state.successfulCronAdds,
|
||||||
getReplayState: () => ({ ...state.replayState }),
|
getReplayState: () => ({ ...state.replayState }),
|
||||||
|
|||||||
@@ -495,6 +495,29 @@ describe("createOpenClawCodingTools", () => {
|
|||||||
expect(cronTools.some((tool) => tool.name === "message")).toBe(true);
|
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", () => {
|
it("can keep message available when a cron route needs it under a provider coding profile", () => {
|
||||||
const providerProfileTools = createOpenClawCodingTools({
|
const providerProfileTools = createOpenClawCodingTools({
|
||||||
config: { tools: { byProvider: { openai: { profile: "coding" } } } },
|
config: { tools: { byProvider: { openai: { profile: "coding" } } } },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createCodingTools, createReadTool } from "@mariozechner/pi-coding-agent";
|
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 { ModelCompatConfig } from "../config/types.models.js";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
|
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
|
||||||
@@ -347,6 +348,10 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
disableMessageTool?: boolean;
|
disableMessageTool?: boolean;
|
||||||
/** Keep the message tool available even when the selected profile omits it. */
|
/** Keep the message tool available even when the selected profile omits it. */
|
||||||
forceMessageTool?: boolean;
|
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). */
|
/** Whether the sender is an owner (required for owner-only tools). */
|
||||||
senderIsOwner?: boolean;
|
senderIsOwner?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -408,7 +413,15 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
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, [
|
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, [
|
||||||
...(profileAlsoAllow ?? []),
|
...(profileAlsoAllow ?? []),
|
||||||
...runtimeProfileAlsoAllow,
|
...runtimeProfileAlsoAllow,
|
||||||
@@ -647,6 +660,7 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
modelHasVision: options?.modelHasVision,
|
modelHasVision: options?.modelHasVision,
|
||||||
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
|
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
|
||||||
disableMessageTool: options?.disableMessageTool,
|
disableMessageTool: options?.disableMessageTool,
|
||||||
|
enableHeartbeatTool,
|
||||||
...(cronSelfRemoveOnlyJobId ? { cronSelfRemoveOnlyJobId } : {}),
|
...(cronSelfRemoveOnlyJobId ? { cronSelfRemoveOnlyJobId } : {}),
|
||||||
requesterAgentIdOverride: agentId,
|
requesterAgentIdOverride: agentId,
|
||||||
requesterSenderId: options?.senderId,
|
requesterSenderId: options?.senderId,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const coreTools = [
|
|||||||
stubActionTool("nodes", ["list", "invoke"]),
|
stubActionTool("nodes", ["list", "invoke"]),
|
||||||
stubActionTool("cron", ["schedule", "cancel"]),
|
stubActionTool("cron", ["schedule", "cancel"]),
|
||||||
stubActionTool("message", ["send", "reply"]),
|
stubActionTool("message", ["send", "reply"]),
|
||||||
|
stubTool("heartbeat_respond"),
|
||||||
stubActionTool("gateway", [
|
stubActionTool("gateway", [
|
||||||
"restart",
|
"restart",
|
||||||
"config.get",
|
"config.get",
|
||||||
@@ -46,7 +47,11 @@ const coreTools = [
|
|||||||
stubTool("pdf"),
|
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", () => ({
|
vi.mock("../openclaw-tools.js", () => ({
|
||||||
createOpenClawTools: createOpenClawToolsMock,
|
createOpenClawTools: createOpenClawToolsMock,
|
||||||
|
|||||||
@@ -221,6 +221,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
|||||||
profiles: ["messaging"],
|
profiles: ["messaging"],
|
||||||
includeInOpenClawGroup: true,
|
includeInOpenClawGroup: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "heartbeat_respond",
|
||||||
|
label: "heartbeat_respond",
|
||||||
|
description: "Record heartbeat outcomes",
|
||||||
|
sectionId: "automation",
|
||||||
|
profiles: [],
|
||||||
|
includeInOpenClawGroup: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "cron",
|
id: "cron",
|
||||||
label: "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 { 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 type { OpenClawConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
clearApprovalNativeRouteStateForTest,
|
clearApprovalNativeRouteStateForTest,
|
||||||
@@ -702,6 +703,7 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
|
|||||||
|
|
||||||
describe("dispatchReplyFromConfig", () => {
|
describe("dispatchReplyFromConfig", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearAgentHarnesses();
|
||||||
const discordTestPlugin = {
|
const discordTestPlugin = {
|
||||||
...createChannelTestPluginBase({
|
...createChannelTestPluginBase({
|
||||||
id: "discord",
|
id: "discord",
|
||||||
@@ -4410,6 +4412,42 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
|
|||||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
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 () => {
|
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
|
||||||
setNoAbort();
|
setNoAbort();
|
||||||
const dispatcher = createDispatcher();
|
const dispatcher = createDispatcher();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
resolveAgentWorkspaceDir,
|
resolveAgentWorkspaceDir,
|
||||||
resolveSessionAgentId,
|
resolveSessionAgentId,
|
||||||
} from "../../agents/agent-scope.js";
|
} from "../../agents/agent-scope.js";
|
||||||
|
import { selectAgentHarness } from "../../agents/harness/selection.js";
|
||||||
import {
|
import {
|
||||||
isToolAllowedByPolicies,
|
isToolAllowedByPolicies,
|
||||||
resolveEffectiveToolPolicy,
|
resolveEffectiveToolPolicy,
|
||||||
@@ -292,6 +293,42 @@ const createShouldEmitVerboseProgress = (params: {
|
|||||||
return params.fallbackLevel !== "off";
|
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 {
|
export type {
|
||||||
DispatchFromConfigParams,
|
DispatchFromConfigParams,
|
||||||
DispatchFromConfigResult,
|
DispatchFromConfigResult,
|
||||||
@@ -625,13 +662,24 @@ export async function dispatchReplyFromConfig(
|
|||||||
chatType === "group" || chatType === "channel"
|
chatType === "group" || chatType === "channel"
|
||||||
? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies)
|
? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.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 =
|
const prefersMessageToolDelivery =
|
||||||
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
|
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
|
||||||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
|
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
|
||||||
ctx.CommandSource !== "native" &&
|
ctx.CommandSource !== "native" &&
|
||||||
(chatType === "group" || chatType === "channel"
|
(chatType === "group" || chatType === "channel"
|
||||||
? configuredVisibleReplies !== "automatic"
|
? effectiveVisibleReplies !== "automatic"
|
||||||
: configuredVisibleReplies === "message_tool"));
|
: effectiveVisibleReplies === "message_tool"));
|
||||||
const runtimeProfileAlsoAllow = prefersMessageToolDelivery ? ["message"] : [];
|
const runtimeProfileAlsoAllow = prefersMessageToolDelivery ? ["message"] : [];
|
||||||
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [
|
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [
|
||||||
...(profileAlsoAllow ?? []),
|
...(profileAlsoAllow ?? []),
|
||||||
@@ -690,6 +738,7 @@ export async function dispatchReplyFromConfig(
|
|||||||
explicitSuppressTyping: params.replyOptions?.suppressTyping === true,
|
explicitSuppressTyping: params.replyOptions?.suppressTyping === true,
|
||||||
shouldSuppressTyping,
|
shouldSuppressTyping,
|
||||||
messageToolAvailable,
|
messageToolAvailable,
|
||||||
|
defaultVisibleReplies: harnessDefaultVisibleReplies,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
sourceReplyDeliveryMode,
|
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", () => {
|
it("lets group/channel config override the global visible reply mode", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSourceReplyDeliveryMode({
|
resolveSourceReplyDeliveryMode({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function resolveSourceReplyDeliveryMode(params: {
|
|||||||
ctx: SourceReplyDeliveryModeContext;
|
ctx: SourceReplyDeliveryModeContext;
|
||||||
requested?: SourceReplyDeliveryMode;
|
requested?: SourceReplyDeliveryMode;
|
||||||
messageToolAvailable?: boolean;
|
messageToolAvailable?: boolean;
|
||||||
|
defaultVisibleReplies?: "automatic" | "message_tool";
|
||||||
}): SourceReplyDeliveryMode {
|
}): SourceReplyDeliveryMode {
|
||||||
if (params.requested) {
|
if (params.requested) {
|
||||||
return params.messageToolAvailable === false && params.requested === "message_tool_only"
|
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;
|
params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
|
||||||
mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
|
mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
|
||||||
} else {
|
} else {
|
||||||
mode =
|
const configuredMode = params.cfg.messages?.visibleReplies ?? params.defaultVisibleReplies;
|
||||||
params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
|
mode = configuredMode === "message_tool" ? "message_tool_only" : "automatic";
|
||||||
}
|
}
|
||||||
if (mode === "message_tool_only" && params.messageToolAvailable === false) {
|
if (mode === "message_tool_only" && params.messageToolAvailable === false) {
|
||||||
return "automatic";
|
return "automatic";
|
||||||
@@ -58,12 +59,14 @@ export function resolveSourceReplyVisibilityPolicy(params: {
|
|||||||
explicitSuppressTyping?: boolean;
|
explicitSuppressTyping?: boolean;
|
||||||
shouldSuppressTyping?: boolean;
|
shouldSuppressTyping?: boolean;
|
||||||
messageToolAvailable?: boolean;
|
messageToolAvailable?: boolean;
|
||||||
|
defaultVisibleReplies?: "automatic" | "message_tool";
|
||||||
}): SourceReplyVisibilityPolicy {
|
}): SourceReplyVisibilityPolicy {
|
||||||
const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({
|
const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
ctx: params.ctx,
|
ctx: params.ctx,
|
||||||
requested: params.requested,
|
requested: params.requested,
|
||||||
messageToolAvailable: params.messageToolAvailable,
|
messageToolAvailable: params.messageToolAvailable,
|
||||||
|
defaultVisibleReplies: params.defaultVisibleReplies,
|
||||||
});
|
});
|
||||||
const sendPolicyDenied = params.sendPolicy === "deny";
|
const sendPolicyDenied = params.sendPolicy === "deny";
|
||||||
const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only";
|
const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only";
|
||||||
|
|||||||
@@ -208,13 +208,18 @@ vi.mock("../infra/heartbeat-wake.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../logging/subsystem.js", () => ({
|
vi.mock("../logging/subsystem.js", () => {
|
||||||
createSubsystemLogger: vi.fn(() => ({
|
const logger = {
|
||||||
info: mocks.logInfo,
|
info: mocks.logInfo,
|
||||||
warn: mocks.logWarn,
|
warn: mocks.logWarn,
|
||||||
error: mocks.logError,
|
error: mocks.logError,
|
||||||
})),
|
child: vi.fn(),
|
||||||
}));
|
};
|
||||||
|
logger.child.mockReturnValue(logger);
|
||||||
|
return {
|
||||||
|
createSubsystemLogger: vi.fn(() => logger),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./server-methods/agent-timestamp.js", () => ({
|
vi.mock("./server-methods/agent-timestamp.js", () => ({
|
||||||
injectTimestamp: mocks.injectTimestamp,
|
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 { resolveEmbeddedSessionLane } from "../agents/pi-embedded-runner/lanes.js";
|
||||||
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
||||||
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
|
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
|
||||||
|
import {
|
||||||
|
getHeartbeatToolNotificationText,
|
||||||
|
resolveHeartbeatToolResponseFromReplyResult,
|
||||||
|
type HeartbeatToolResponse,
|
||||||
|
} from "../auto-reply/heartbeat-tool-response.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
isHeartbeatContentEffectivelyEmpty,
|
isHeartbeatContentEffectivelyEmpty,
|
||||||
@@ -279,6 +284,15 @@ export function resolveHeartbeatPrompt(cfg: OpenClawConfig, heartbeat?: Heartbea
|
|||||||
return resolveHeartbeatPromptText(heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt);
|
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) {
|
function resolveHeartbeatAckMaxChars(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) {
|
||||||
return Math.max(
|
return Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -580,6 +594,17 @@ function normalizeHeartbeatReply(
|
|||||||
return { shouldSkip: false, text: finalText, hasMedia };
|
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 = {
|
type HeartbeatReasonFlags = {
|
||||||
isExecEventReason: boolean;
|
isExecEventReason: boolean;
|
||||||
isCronEventReason: boolean;
|
isCronEventReason: boolean;
|
||||||
@@ -1221,8 +1246,9 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
|
consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const promptWithHeartbeatTool = appendHeartbeatResponseToolPrompt(prompt);
|
||||||
const ctx = {
|
const ctx = {
|
||||||
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
|
Body: appendCronStyleCurrentTimeLine(promptWithHeartbeatTool, cfg, startedAt),
|
||||||
From: sender,
|
From: sender,
|
||||||
To: sender,
|
To: sender,
|
||||||
OriginatingChannel:
|
OriginatingChannel:
|
||||||
@@ -1344,13 +1370,45 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
const getReplyFromConfig =
|
const getReplyFromConfig =
|
||||||
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
|
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
|
||||||
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
|
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
|
||||||
|
const heartbeatToolResponse = resolveHeartbeatToolResponseFromReplyResult(replyResult);
|
||||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||||
const includeReasoning = heartbeat?.includeReasoning === true;
|
const includeReasoning = heartbeat?.includeReasoning === true;
|
||||||
const reasoningPayloads = includeReasoning
|
const reasoningPayloads = includeReasoning
|
||||||
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
||||||
: [];
|
: [];
|
||||||
|
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({
|
await restoreHeartbeatUpdatedAt({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -1378,15 +1436,20 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
const normalized = heartbeatToolResponse
|
||||||
const responsePrefix = resolveHeartbeatResponsePrefix();
|
? normalizeHeartbeatToolNotification(heartbeatToolResponse, responsePrefix)
|
||||||
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
|
: 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.
|
// 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.
|
// 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,
|
// Also, if normalized.text is empty due to token stripping but we have exec completion,
|
||||||
// fall back to the original reply text.
|
// fall back to the original reply text.
|
||||||
const execFallbackText =
|
const execFallbackText =
|
||||||
hasRelayableExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
|
!heartbeatToolResponse &&
|
||||||
|
hasRelayableExecCompletion &&
|
||||||
|
!normalized.text.trim() &&
|
||||||
|
replyPayload?.text?.trim()
|
||||||
? replyPayload.text.trim()
|
? replyPayload.text.trim()
|
||||||
: null;
|
: null;
|
||||||
if (execFallbackText) {
|
if (execFallbackText) {
|
||||||
@@ -1423,7 +1486,10 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
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.
|
// Suppress duplicate heartbeats (same payload) within a short window.
|
||||||
// This prevents "nagging" when nothing changed but the model repeats the same items.
|
// This prevents "nagging" when nothing changed but the model repeats the same items.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type {
|
|||||||
AgentHarnessAttemptResult,
|
AgentHarnessAttemptResult,
|
||||||
AgentHarnessCompactParams,
|
AgentHarnessCompactParams,
|
||||||
AgentHarnessCompactResult,
|
AgentHarnessCompactResult,
|
||||||
|
AgentHarnessDeliveryDefaults,
|
||||||
AgentHarnessResultClassification,
|
AgentHarnessResultClassification,
|
||||||
AgentHarnessResetParams,
|
AgentHarnessResetParams,
|
||||||
AgentHarnessSupport,
|
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 { EmbeddedPiCompactResult } from "../agents/pi-embedded-runner/types.js";
|
||||||
export type { AnyAgentTool } from "../agents/tools/common.js";
|
export type { AnyAgentTool } from "../agents/tools/common.js";
|
||||||
export type { MessagingToolSend } from "../agents/pi-embedded-messaging.types.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 { AgentApprovalEventData, AgentEventPayload } from "../infra/agent-events.js";
|
||||||
export type { ExecApprovalDecision } from "../infra/exec-approvals.js";
|
export type { ExecApprovalDecision } from "../infra/exec-approvals.js";
|
||||||
export type { NormalizedUsage } from "../agents/usage.js";
|
export type { NormalizedUsage } from "../agents/usage.js";
|
||||||
@@ -74,6 +76,10 @@ export {
|
|||||||
selectDefaultNodeFromList,
|
selectDefaultNodeFromList,
|
||||||
} from "../agents/tools/nodes-utils.js";
|
} from "../agents/tools/nodes-utils.js";
|
||||||
export { formatToolAggregate } from "../auto-reply/tool-meta.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 { isMessagingTool, isMessagingToolSendAction } from "../agents/pi-embedded-messaging.js";
|
||||||
export {
|
export {
|
||||||
extractToolResultMediaArtifact,
|
extractToolResultMediaArtifact,
|
||||||
|
|||||||
Reference in New Issue
Block a user