fix: refine HEARTBEAT_OK handling
This commit is contained in:
@@ -35,7 +35,7 @@
|
|||||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||||
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
||||||
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
||||||
- Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages.
|
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
|
||||||
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
||||||
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
||||||
- Skills: switch imsg installer to brew tap formula.
|
- Skills: switch imsg installer to brew tap formula.
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ surface anything that needs attention without spamming the user.
|
|||||||
|
|
||||||
## Prompt contract
|
## Prompt contract
|
||||||
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
||||||
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`.
|
- If nothing needs attention, the model should reply **exactly** `HEARTBEAT_OK`.
|
||||||
- Any response containing `HEARTBEAT_OK` is treated as an ack and discarded.
|
- During heartbeat runs, Clawdis treats `HEARTBEAT_OK` as an ack when it appears at
|
||||||
|
the **start or end** of the reply. Clawdis strips the token and discards the
|
||||||
|
reply if the remaining content is **≤ 30 characters**.
|
||||||
|
- If `HEARTBEAT_OK` is 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.
|
||||||
|
|
||||||
|
### Stray `HEARTBEAT_OK` outside heartbeats
|
||||||
|
If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
|
||||||
|
normal (non-heartbeat) reply, Clawdis strips the token and logs a verbose
|
||||||
|
message. If the reply is only `HEARTBEAT_OK`, it is dropped.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function buildAgentSystemPromptAppend(params: {
|
|||||||
"## Heartbeats",
|
"## Heartbeats",
|
||||||
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
||||||
"HEARTBEAT_OK",
|
"HEARTBEAT_OK",
|
||||||
'Any response containing "HEARTBEAT_OK" is treated as a heartbeat ack and will not be delivered.',
|
'Clawdis treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
|
||||||
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
||||||
"",
|
"",
|
||||||
"## Runtime",
|
"## Runtime",
|
||||||
|
|||||||
@@ -5,55 +5,89 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
|
|||||||
|
|
||||||
describe("stripHeartbeatToken", () => {
|
describe("stripHeartbeatToken", () => {
|
||||||
it("skips empty or token-only replies", () => {
|
it("skips empty or token-only replies", () => {
|
||||||
expect(stripHeartbeatToken(undefined)).toEqual({
|
expect(stripHeartbeatToken(undefined, { mode: "heartbeat" })).toEqual({
|
||||||
shouldSkip: true,
|
shouldSkip: true,
|
||||||
text: "",
|
text: "",
|
||||||
|
didStrip: false,
|
||||||
});
|
});
|
||||||
expect(stripHeartbeatToken(" ")).toEqual({
|
expect(stripHeartbeatToken(" ", { mode: "heartbeat" })).toEqual({
|
||||||
shouldSkip: true,
|
shouldSkip: true,
|
||||||
text: "",
|
text: "",
|
||||||
|
didStrip: false,
|
||||||
});
|
});
|
||||||
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({
|
expect(stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" })).toEqual(
|
||||||
|
{
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
didStrip: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops heartbeats with small junk in heartbeat mode", () => {
|
||||||
|
expect(
|
||||||
|
stripHeartbeatToken("HEARTBEAT_OK 🦞", { mode: "heartbeat" }),
|
||||||
|
).toEqual({
|
||||||
shouldSkip: true,
|
shouldSkip: true,
|
||||||
text: "",
|
text: "",
|
||||||
|
didStrip: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
stripHeartbeatToken(`🦞 ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
|
||||||
|
).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
didStrip: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips any reply that includes the heartbeat token", () => {
|
it("drops short remainder in heartbeat mode", () => {
|
||||||
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
|
expect(
|
||||||
shouldSkip: true,
|
stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
|
||||||
text: "",
|
).toEqual({
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK _OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
|
||||||
text: "",
|
|
||||||
});
|
|
||||||
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
|
||||||
shouldSkip: true,
|
shouldSkip: true,
|
||||||
text: "",
|
text: "",
|
||||||
|
didStrip: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps non-heartbeat content", () => {
|
it("keeps heartbeat replies when remaining content exceeds threshold", () => {
|
||||||
expect(stripHeartbeatToken("hello")).toEqual({
|
const long = "A".repeat(31);
|
||||||
|
expect(
|
||||||
|
stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
|
||||||
|
).toEqual({
|
||||||
|
shouldSkip: false,
|
||||||
|
text: long,
|
||||||
|
didStrip: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips token at edges for normal messages", () => {
|
||||||
|
expect(
|
||||||
|
stripHeartbeatToken(`${HEARTBEAT_TOKEN} hello`, { mode: "message" }),
|
||||||
|
).toEqual({
|
||||||
shouldSkip: false,
|
shouldSkip: false,
|
||||||
text: "hello",
|
text: "hello",
|
||||||
|
didStrip: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN}`, { mode: "message" }),
|
||||||
|
).toEqual({
|
||||||
|
shouldSkip: false,
|
||||||
|
text: "hello",
|
||||||
|
didStrip: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch token in the middle", () => {
|
||||||
|
expect(
|
||||||
|
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN} there`, {
|
||||||
|
mode: "message",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
shouldSkip: false,
|
||||||
|
text: `hello ${HEARTBEAT_TOKEN} there`,
|
||||||
|
didStrip: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,69 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
|
|||||||
|
|
||||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||||
|
|
||||||
export function stripHeartbeatToken(raw?: string) {
|
export type StripHeartbeatMode = "heartbeat" | "message";
|
||||||
if (!raw) return { shouldSkip: true, text: "" };
|
|
||||||
const trimmed = raw.trim();
|
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
|
||||||
if (!trimmed) return { shouldSkip: true, text: "" };
|
let text = raw.trim();
|
||||||
if (trimmed.includes(HEARTBEAT_TOKEN)) {
|
if (!text) return { text: "", didStrip: false };
|
||||||
return { shouldSkip: true, text: "" };
|
|
||||||
|
const token = HEARTBEAT_TOKEN;
|
||||||
|
if (!text.includes(token)) return { text, didStrip: false };
|
||||||
|
|
||||||
|
let didStrip = false;
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
const next = text.trim();
|
||||||
|
if (next.startsWith(token)) {
|
||||||
|
const after = next.slice(token.length).trimStart();
|
||||||
|
text = after;
|
||||||
|
didStrip = true;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (next.endsWith(token)) {
|
||||||
|
const before = next.slice(0, Math.max(0, next.length - token.length));
|
||||||
|
text = before.trimEnd();
|
||||||
|
didStrip = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { shouldSkip: false, text: trimmed };
|
|
||||||
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||||
|
return { text: collapsed, didStrip };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripHeartbeatToken(
|
||||||
|
raw?: string,
|
||||||
|
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
|
||||||
|
) {
|
||||||
|
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
|
||||||
|
|
||||||
|
const mode: StripHeartbeatMode = opts.mode ?? "message";
|
||||||
|
const maxAckChars = Math.max(0, opts.maxAckChars ?? 30);
|
||||||
|
|
||||||
|
if (!trimmed.includes(HEARTBEAT_TOKEN)) {
|
||||||
|
return { shouldSkip: false, text: trimmed, didStrip: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = stripTokenAtEdges(trimmed);
|
||||||
|
if (!stripped.didStrip) {
|
||||||
|
return { shouldSkip: false, text: trimmed, didStrip: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stripped.text) {
|
||||||
|
return { shouldSkip: true, text: "", didStrip: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "heartbeat") {
|
||||||
|
const rest = stripped.text.trim();
|
||||||
|
if (rest.length <= maxAckChars) {
|
||||||
|
return { shouldSkip: true, text: "", didStrip: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldSkip: false, text: stripped.text, didStrip: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,31 @@ describe("trigger handling", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 1,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await getReplyFromConfig(
|
||||||
|
{
|
||||||
|
Body: "hello",
|
||||||
|
From: "+1002",
|
||||||
|
To: "+2000",
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
makeCfg(home),
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("updates group activation when the owner sends /activation", async () => {
|
it("updates group activation when the owner sends /activation", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
normalizeGroupActivation,
|
normalizeGroupActivation,
|
||||||
parseActivationCommand,
|
parseActivationCommand,
|
||||||
} from "./group-activation.js";
|
} from "./group-activation.js";
|
||||||
|
import { stripHeartbeatToken } from "./heartbeat.js";
|
||||||
import { extractModelDirective } from "./model.js";
|
import { extractModelDirective } from "./model.js";
|
||||||
import { buildStatusMessage } from "./status.js";
|
import { buildStatusMessage } from "./status.js";
|
||||||
import type { MsgContext, TemplateContext } from "./templating.js";
|
import type { MsgContext, TemplateContext } from "./templating.js";
|
||||||
@@ -62,7 +63,7 @@ import {
|
|||||||
type ThinkLevel,
|
type ThinkLevel,
|
||||||
type VerboseLevel,
|
type VerboseLevel,
|
||||||
} from "./thinking.js";
|
} from "./thinking.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "./tokens.js";
|
import { SILENT_REPLY_TOKEN } from "./tokens.js";
|
||||||
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
@@ -1191,7 +1192,7 @@ export async function getReplyFromConfig(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let suppressedByHeartbeatAck = false;
|
let didLogHeartbeatStrip = false;
|
||||||
try {
|
try {
|
||||||
if (shouldEagerType) {
|
if (shouldEagerType) {
|
||||||
await startTypingLoop();
|
await startTypingLoop();
|
||||||
@@ -1221,20 +1222,24 @@ export async function getReplyFromConfig(
|
|||||||
runId,
|
runId,
|
||||||
onPartialReply: opts?.onPartialReply
|
onPartialReply: opts?.onPartialReply
|
||||||
? async (payload) => {
|
? async (payload) => {
|
||||||
if (
|
let text = payload.text;
|
||||||
!opts?.isHeartbeat &&
|
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||||
payload.text?.includes(HEARTBEAT_TOKEN)
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
) {
|
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||||
suppressedByHeartbeatAck = true;
|
didLogHeartbeatStrip = true;
|
||||||
logVerbose(
|
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||||
"Suppressing partial reply: detected HEARTBEAT_OK token",
|
}
|
||||||
);
|
if (
|
||||||
return;
|
stripped.shouldSkip &&
|
||||||
|
(payload.mediaUrls?.length ?? 0) === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
if (suppressedByHeartbeatAck) return;
|
await startTypingOnText(text);
|
||||||
await startTypingOnText(payload.text);
|
|
||||||
await opts.onPartialReply?.({
|
await opts.onPartialReply?.({
|
||||||
text: payload.text,
|
text,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1242,22 +1247,23 @@ export async function getReplyFromConfig(
|
|||||||
shouldEmitToolResult,
|
shouldEmitToolResult,
|
||||||
onToolResult: opts?.onToolResult
|
onToolResult: opts?.onToolResult
|
||||||
? async (payload) => {
|
? async (payload) => {
|
||||||
if (
|
let text = payload.text;
|
||||||
!opts?.isHeartbeat &&
|
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||||
payload.text?.includes(HEARTBEAT_TOKEN)
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
) {
|
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||||
suppressedByHeartbeatAck = true;
|
didLogHeartbeatStrip = true;
|
||||||
logVerbose(
|
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||||
"Suppressing tool result: detected HEARTBEAT_OK token",
|
}
|
||||||
);
|
if (
|
||||||
return;
|
stripped.shouldSkip &&
|
||||||
|
(payload.mediaUrls?.length ?? 0) === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
if (suppressedByHeartbeatAck) return;
|
await startTypingOnText(text);
|
||||||
await startTypingOnText(payload.text);
|
await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
|
||||||
await opts.onToolResult?.({
|
|
||||||
text: payload.text,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
@@ -1288,22 +1294,28 @@ export async function getReplyFromConfig(
|
|||||||
|
|
||||||
const payloadArray = runResult.payloads ?? [];
|
const payloadArray = runResult.payloads ?? [];
|
||||||
if (payloadArray.length === 0) return undefined;
|
if (payloadArray.length === 0) return undefined;
|
||||||
if (
|
|
||||||
suppressedByHeartbeatAck ||
|
const sanitizedPayloads = opts?.isHeartbeat
|
||||||
(!opts?.isHeartbeat &&
|
? payloadArray
|
||||||
payloadArray.some((payload) => payload.text?.includes(HEARTBEAT_TOKEN)))
|
: payloadArray.flatMap((payload) => {
|
||||||
) {
|
const text = payload.text;
|
||||||
logVerbose("Suppressing reply: detected HEARTBEAT_OK token");
|
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||||
return undefined;
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
}
|
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||||
const shouldSignalTyping = payloadArray.some((payload) => {
|
didLogHeartbeatStrip = true;
|
||||||
|
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||||
|
}
|
||||||
|
const hasMedia =
|
||||||
|
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
|
if (stripped.shouldSkip && !hasMedia) return [];
|
||||||
|
return [{ ...payload, text: stripped.text }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sanitizedPayloads.length === 0) return undefined;
|
||||||
|
|
||||||
|
const shouldSignalTyping = sanitizedPayloads.some((payload) => {
|
||||||
const trimmed = payload.text?.trim();
|
const trimmed = payload.text?.trim();
|
||||||
if (
|
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
|
||||||
trimmed &&
|
|
||||||
trimmed !== SILENT_REPLY_TOKEN &&
|
|
||||||
!trimmed.includes(HEARTBEAT_TOKEN)
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
if (payload.mediaUrl) return true;
|
if (payload.mediaUrl) return true;
|
||||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||||
return false;
|
return false;
|
||||||
@@ -1356,11 +1368,11 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||||
let finalPayloads = payloadArray;
|
let finalPayloads = sanitizedPayloads;
|
||||||
if (resolvedVerboseLevel === "on" && isNewSession) {
|
if (resolvedVerboseLevel === "on" && isNewSession) {
|
||||||
finalPayloads = [
|
finalPayloads = [
|
||||||
{ text: `🧭 New session: ${sessionIdFinal}` },
|
{ text: `🧭 New session: ${sessionIdFinal}` },
|
||||||
...payloadArray,
|
...finalPayloads,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,10 @@ function normalizeHeartbeatReply(
|
|||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
responsePrefix?: string,
|
responsePrefix?: string,
|
||||||
) {
|
) {
|
||||||
const stripped = stripHeartbeatToken(payload.text);
|
const stripped = stripHeartbeatToken(payload.text, {
|
||||||
|
mode: "heartbeat",
|
||||||
|
maxAckChars: 30,
|
||||||
|
});
|
||||||
const hasMedia = Boolean(
|
const hasMedia = Boolean(
|
||||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ export { stripHeartbeatToken };
|
|||||||
function isSilentReply(payload?: ReplyPayload): boolean {
|
function isSilentReply(payload?: ReplyPayload): boolean {
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
const text = payload.text?.trim();
|
const text = payload.text?.trim();
|
||||||
if (text?.includes(HEARTBEAT_TOKEN)) return true;
|
|
||||||
if (!text || text !== SILENT_REPLY_TOKEN) return false;
|
if (!text || text !== SILENT_REPLY_TOKEN) return false;
|
||||||
if (payload.mediaUrl || payload.mediaUrls?.length) return false;
|
if (payload.mediaUrl || payload.mediaUrls?.length) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -337,7 +336,10 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
const hasMedia = Boolean(
|
const hasMedia = Boolean(
|
||||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
const stripped = stripHeartbeatToken(replyPayload.text, {
|
||||||
|
mode: "heartbeat",
|
||||||
|
maxAckChars: 30,
|
||||||
|
});
|
||||||
if (stripped.shouldSkip && !hasMedia) {
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
@@ -1034,6 +1036,7 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responsePrefix = cfg.messages?.responsePrefix;
|
const responsePrefix = cfg.messages?.responsePrefix;
|
||||||
|
let didLogHeartbeatStrip = false;
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
let toolSendChain: Promise<void> = Promise.resolve();
|
let toolSendChain: Promise<void> = Promise.resolve();
|
||||||
const sendToolResult = (payload: ReplyPayload) => {
|
const sendToolResult = (payload: ReplyPayload) => {
|
||||||
@@ -1046,6 +1049,20 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
if (isSilentReply(payload)) return;
|
if (isSilentReply(payload)) return;
|
||||||
const toolPayload: ReplyPayload = { ...payload };
|
const toolPayload: ReplyPayload = { ...payload };
|
||||||
|
if (toolPayload.text?.includes(HEARTBEAT_TOKEN)) {
|
||||||
|
const stripped = stripHeartbeatToken(toolPayload.text, {
|
||||||
|
mode: "message",
|
||||||
|
});
|
||||||
|
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||||
|
didLogHeartbeatStrip = true;
|
||||||
|
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
|
||||||
|
}
|
||||||
|
const hasMedia = Boolean(
|
||||||
|
toolPayload.mediaUrl || (toolPayload.mediaUrls?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (stripped.shouldSkip && !hasMedia) return;
|
||||||
|
toolPayload.text = stripped.text;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
responsePrefix &&
|
responsePrefix &&
|
||||||
toolPayload.text &&
|
toolPayload.text &&
|
||||||
@@ -1134,6 +1151,20 @@ export async function monitorWebProvider(
|
|||||||
await toolSendChain;
|
await toolSendChain;
|
||||||
|
|
||||||
for (const replyPayload of sendableReplies) {
|
for (const replyPayload of sendableReplies) {
|
||||||
|
if (replyPayload.text?.includes(HEARTBEAT_TOKEN)) {
|
||||||
|
const stripped = stripHeartbeatToken(replyPayload.text, {
|
||||||
|
mode: "message",
|
||||||
|
});
|
||||||
|
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||||
|
didLogHeartbeatStrip = true;
|
||||||
|
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
|
||||||
|
}
|
||||||
|
const hasMedia = Boolean(
|
||||||
|
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (stripped.shouldSkip && !hasMedia) continue;
|
||||||
|
replyPayload.text = stripped.text;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
responsePrefix &&
|
responsePrefix &&
|
||||||
replyPayload.text &&
|
replyPayload.text &&
|
||||||
|
|||||||
Reference in New Issue
Block a user