Skip to content

Commit 808add5

Browse files
fix(heartbeat): reuse the shared SDK reasoning classifier in the reply selector
1 parent f5d9ad1 commit 808add5

3 files changed

Lines changed: 30 additions & 36 deletions

File tree

src/auto-reply/heartbeat-reply-payload.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,15 @@ describe("resolveHeartbeatReplyPayload", () => {
5959
const blockquoted: ReplyPayload = { text: "Thinking... _weighing the options_" };
6060
expect(resolveHeartbeatReplyPayload(blockquoted)).toBeUndefined();
6161
});
62+
63+
it("skips a trailing lowercase 'reasoning:' payload and returns the final answer", () => {
64+
const answer: ReplyPayload = { text: "All clear" };
65+
const lowercased: ReplyPayload = { text: "reasoning: because nothing changed" };
66+
expect(resolveHeartbeatReplyPayload([answer, lowercased])).toBe(answer);
67+
});
68+
69+
it("returns undefined for a Markdown blockquoted thinking payload", () => {
70+
const quoted: ReplyPayload = { text: "> thinking... _weighing the options_" };
71+
expect(resolveHeartbeatReplyPayload(quoted)).toBeUndefined();
72+
});
6273
});

src/auto-reply/heartbeat-reply-payload.ts

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,17 @@
11
// Heartbeat reply payload selector for multi-payload auto-reply results.
2-
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
2+
import { hasOutboundReplyContent, isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload";
33
import type { ReplyPayload } from "./types.js";
44

5-
// Formatted reasoning prefixes the heartbeat reasoning lane classifies as
6-
// reasoning even when the `isReasoning` flag is absent: legacy "Reasoning:"
7-
// text and blockquoted "Thinking..._". Kept in sync with
8-
// resolveHeartbeatReasoningPayloads (heartbeat-runner) so the selector skips
9-
// exactly the payloads the reasoning lane delivers separately.
10-
const FORMATTED_REASONING_PREFIX = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u;
11-
12-
/** Whether text already carries a formatted reasoning prefix (legacy reasoning). */
13-
export function hasFormattedReasoningPrefix(text: string): boolean {
14-
return FORMATTED_REASONING_PREFIX.test(text.trimStart());
15-
}
16-
17-
/**
18-
* Whether a payload is reasoning (flagged or legacy-formatted) and so must not
19-
* become the user-visible heartbeat reply.
20-
*/
21-
export function isReasoningReplyPayload(payload: ReplyPayload): boolean {
22-
if (payload.isReasoning === true) {
23-
return true;
24-
}
25-
const text = typeof payload.text === "string" ? payload.text : "";
26-
return hasFormattedReasoningPrefix(text);
27-
}
28-
295
/**
306
* Pick the last outbound-capable reply payload for heartbeat delivery.
317
*
32-
* Reasoning payloads are skipped: heartbeat reasoning is delivered separately
33-
* and only when `includeReasoning` is enabled. Without this guard a trailing
34-
* reasoning payload (flagged via `isReasoning` or legacy "Reasoning:" /
35-
* blockquoted "Thinking" text, which reasoning models can emit after the final
36-
* answer) would be selected as the user-visible heartbeat reply.
8+
* Reasoning payloads are skipped using the shared SDK classifier
9+
* `isReasoningReplyPayload`, which recognizes the `isReasoning` flag plus the
10+
* common reasoning/thinking text prefixes (including lowercased and Markdown
11+
* blockquoted forms). Heartbeat reasoning is delivered separately and only when
12+
* `includeReasoning` is enabled; without this guard a trailing reasoning
13+
* payload (which reasoning models can emit after the final answer) would be
14+
* selected as the user-visible heartbeat reply.
3715
*/
3816
export function resolveHeartbeatReplyPayload(
3917
replyResult: ReplyPayload | ReplyPayload[] | undefined,

src/infra/heartbeat-runner.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@openclaw/normalization-core/string-coerce";
1010
import {
1111
hasOutboundReplyContent,
12+
isReasoningReplyPayload,
1213
resolveSendableOutboundReplyParts,
1314
} from "openclaw/plugin-sdk/reply-payload";
1415
import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js";
@@ -25,11 +26,7 @@ import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
2526
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
2627
import { resolvePersistedSessionRuntimeId } from "../agents/session-runtime-compat.js";
2728
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
28-
import {
29-
hasFormattedReasoningPrefix,
30-
isReasoningReplyPayload,
31-
resolveHeartbeatReplyPayload,
32-
} from "../auto-reply/heartbeat-reply-payload.js";
29+
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
3330
import {
3431
getHeartbeatToolNotificationText,
3532
resolveHeartbeatToolResponseFromReplyResult,
@@ -742,6 +739,12 @@ function resolveStaleHeartbeatIsolatedSessionKey(params: {
742739
return undefined;
743740
}
744741

742+
// Display-format check (not a classifier): whether reasoning text is already
743+
// rendered in the heartbeat "Reasoning:" / "Thinking..._" form, so it should be
744+
// delivered as-is rather than re-wrapped by formatReasoningMessage. Reasoning
745+
// classification itself is the shared SDK `isReasoningReplyPayload`.
746+
const HEARTBEAT_REASONING_DISPLAY_PREFIX = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u;
747+
745748
function resolveHeartbeatReasoningPayloads(
746749
replyResult: ReplyPayload | ReplyPayload[] | undefined,
747750
): ReplyPayload[] {
@@ -756,7 +759,9 @@ function resolveHeartbeatReasoningPayloads(
756759
continue;
757760
}
758761

759-
const formattedText = hasFormattedReasoningPrefix(text) ? text : formatReasoningMessage(text);
762+
const formattedText = HEARTBEAT_REASONING_DISPLAY_PREFIX.test(text.trimStart())
763+
? text
764+
: formatReasoningMessage(text);
760765
if (!formattedText.trim()) {
761766
continue;
762767
}

0 commit comments

Comments
 (0)