Skip to content

Commit 439d8ed

Browse files
authored
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
1 parent bee47a8 commit 439d8ed

39 files changed

Lines changed: 780 additions & 25 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Docs: https://docs.openclaw.ai
2727
- 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.
2828
- 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.
2929
- 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.
30+
- 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.
31+
- 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.
3032

3133
### Fixes
3234

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
37787172adf7a55a32097599b4bf5729fc7138c8743c6f4c9d58fc8d01df72a1 plugin-sdk-api-baseline.json
2-
0ec4957528477832085c638a5f7f691c878ba199f3e81f330f162c27cfd9ebf4 plugin-sdk-api-baseline.jsonl
1+
42cb8c8be1e10a42891035fc402022ab1cc2eb941e7e69a9a2f8a6d01a30bd3e plugin-sdk-api-baseline.json
2+
4bafb4519802e0daeb8458d9aed9b09d2fc51755f02d1568a368d814c6f7930a plugin-sdk-api-baseline.jsonl

docs/channels/groups.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ If the message tool is unavailable under the active tool policy, OpenClaw falls
4747
back to automatic visible replies instead of silently suppressing the response.
4848
`openclaw doctor` warns about this mismatch.
4949

50-
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.
50+
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.
5151

5252
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.
5353

docs/gateway/config-channels.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,7 @@ See the full channel index: [Channels](/channels).
774774

775775
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
776776

777-
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"`.
777+
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.
778778

779779
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.
780780

@@ -789,7 +789,7 @@ The gateway hot-reloads `messages` config after the file is saved. Restart only
789789
```json5
790790
{
791791
messages: {
792-
visibleReplies: "automatic", // global default for direct/source chats
792+
visibleReplies: "automatic", // global default for direct/source chats; Codex harness defaults unset direct chats to message_tool
793793
groupChat: {
794794
historyLimit: 50,
795795
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
803803

804804
`messages.groupChat.historyLimit` sets the global default. Channels can override with `channels.<channel>.historyLimit` (or per-account). Set `0` to disable.
805805

806-
`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.
806+
`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.
807807

808808
#### DM history limits
809809

docs/gateway/heartbeat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub
8282
## Response contract
8383

8484
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
85+
- 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.
8586
- 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).
8687
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated specially.
8788
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.

docs/plugins/codex-harness.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ discovery, native thread resume, native compaction, and app-server execution.
1515
OpenClaw still owns chat channels, session files, model selection, tools,
1616
approvals, media delivery, and the visible transcript mirror.
1717

18+
When a source chat turn runs through the Codex harness, visible replies default
19+
to the OpenClaw `message` tool if the deployment has not explicitly configured
20+
`messages.visibleReplies`. The agent can still finish its Codex turn privately;
21+
it only posts to the channel when it calls `message(action="send")`. Set
22+
`messages.visibleReplies: "automatic"` to keep direct-chat final replies on the
23+
legacy automatic delivery path.
24+
25+
Codex heartbeat turns also get the `heartbeat_respond` tool by default, so the
26+
agent can record whether the wake should stay quiet or notify without encoding
27+
that control flow in final text.
28+
1829
If you are trying to orient yourself, start with
1930
[Agent runtimes](/concepts/agent-runtimes). The short version is:
2031
`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,
583594
OpenClaw does not expose dynamic tools that duplicate Codex-native workspace
584595
operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and
585596
`update_plan`. OpenClaw integration tools such as messaging, sessions, media,
586-
cron, browser, nodes, gateway, and `web_search` remain available.
597+
cron, browser, nodes, gateway, `heartbeat_respond`, and `web_search` remain
598+
available.
587599

588600
Supported top-level Codex plugin fields:
589601

extensions/codex/harness.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export function createCodexAppServerAgentHarness(options?: {
2323
return {
2424
id: options?.id ?? "codex",
2525
label: options?.label ?? "Codex agent harness",
26+
deliveryDefaults: {
27+
sourceVisibleReplies: "message_tool",
28+
},
2629
supports: (ctx) => {
2730
const provider = ctx.provider.trim().toLowerCase();
2831
if (providerIds.has(provider)) {

extensions/codex/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe("codex plugin", () => {
4444
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
4545
id: "codex",
4646
label: "Codex agent harness",
47+
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
4748
dispose: expect.any(Function),
4849
});
4950
expect(registerMediaUnderstandingProvider.mock.calls[0]?.[0]).toMatchObject({
@@ -89,6 +90,7 @@ describe("codex plugin", () => {
8990
it("only claims the codex provider by default", () => {
9091
const harness = createCodexAppServerAgentHarness();
9192

93+
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
9294
expect(
9395
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
9496
.supported,

extensions/codex/src/app-server/dynamic-tools.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
22
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
3-
import { wrapToolWithBeforeToolCallHook } from "openclaw/plugin-sdk/agent-harness-runtime";
3+
import {
4+
HEARTBEAT_RESPONSE_TOOL_NAME,
5+
wrapToolWithBeforeToolCallHook,
6+
} from "openclaw/plugin-sdk/agent-harness-runtime";
47
import {
58
initializeGlobalHookRunner,
69
resetGlobalHookRunner,
@@ -212,6 +215,38 @@ describe("createCodexDynamicToolBridge", () => {
212215
});
213216
});
214217

218+
it("records heartbeat response tool outcomes", async () => {
219+
const bridge = createBridgeWithToolResult(
220+
HEARTBEAT_RESPONSE_TOOL_NAME,
221+
textToolResult("Recorded.", {
222+
status: "recorded",
223+
outcome: "needs_attention",
224+
notify: true,
225+
summary: "Build is blocked.",
226+
notificationText: "Build is blocked on missing credentials.",
227+
priority: "high",
228+
}),
229+
);
230+
231+
const result = await bridge.handleToolCall({
232+
threadId: "thread-1",
233+
turnId: "turn-1",
234+
callId: "call-1",
235+
namespace: null,
236+
tool: HEARTBEAT_RESPONSE_TOOL_NAME,
237+
arguments: {},
238+
});
239+
240+
expect(result).toEqual(expectInputText("Recorded."));
241+
expect(bridge.telemetry.heartbeatToolResponse).toEqual({
242+
outcome: "needs_attention",
243+
notify: true,
244+
summary: "Build is blocked.",
245+
notificationText: "Build is blocked on missing credentials.",
246+
priority: "high",
247+
});
248+
});
249+
215250
it("applies agent tool result middleware from the active plugin registry", async () => {
216251
const registry = createEmptyPluginRegistry();
217252
const handler = vi.fn(

extensions/codex/src/app-server/dynamic-tools.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import {
55
createCodexAppServerToolResultExtensionRunner,
66
extractToolResultMediaArtifact,
77
filterToolResultMediaUrls,
8+
HEARTBEAT_RESPONSE_TOOL_NAME,
89
isToolWrappedWithBeforeToolCallHook,
910
isMessagingTool,
1011
isMessagingToolSendAction,
12+
normalizeHeartbeatToolResponse,
1113
runAgentHarnessAfterToolCallHook,
1214
type AnyAgentTool,
15+
type HeartbeatToolResponse,
1316
type MessagingToolSend,
1417
wrapToolWithBeforeToolCallHook,
1518
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -32,6 +35,7 @@ export type CodexDynamicToolBridge = {
3235
messagingToolSentTexts: string[];
3336
messagingToolSentMediaUrls: string[];
3437
messagingToolSentTargets: MessagingToolSend[];
38+
heartbeatToolResponse?: HeartbeatToolResponse;
3539
toolMediaUrls: string[];
3640
toolAudioAsVoice: boolean;
3741
successfulCronAdds?: number;
@@ -190,6 +194,12 @@ function collectToolTelemetry(params: {
190194
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
191195
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
192196
}
197+
if (!params.isError && params.toolName === HEARTBEAT_RESPONSE_TOOL_NAME) {
198+
const response = normalizeHeartbeatToolResponse(params.result?.details);
199+
if (response) {
200+
params.telemetry.heartbeatToolResponse = response;
201+
}
202+
}
193203
if (!params.isError && params.result) {
194204
const media = extractToolResultMediaArtifact(params.result);
195205
if (media) {
@@ -256,6 +266,7 @@ function isToolResultError(result: AgentToolResult<unknown>): boolean {
256266
status !== "ok" &&
257267
status !== "success" &&
258268
status !== "completed" &&
269+
status !== "recorded" &&
259270
status !== "running"
260271
);
261272
}

0 commit comments

Comments
 (0)