mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 23:55:12 +00:00
fix(heartbeat): suppress metadata-only exec completion noise
This commit is contained in:
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc.
|
||||
- Heartbeat/exec: consume successful metadata-only async exec completions silently so Telegram and other chat surfaces no longer ask users for missing command logs after `No session found`. Fixes #74595. Thanks @gkoch02.
|
||||
- Web fetch: add a documented `tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` opt-in and thread it through cache keys and DNS/IP checks so trusted fake-IP proxy stacks using `fc00::/7` can work without broad private-network access. Fixes #74351. Thanks @jeffrey701.
|
||||
- OpenAI Codex: restore `/verbose full` persistence and app-server tool-output forwarding, and retry Gateway E2E temp-home cleanup so debug runs do not regress on stale validation or cleanup flakes. Thanks @vincentkoc.
|
||||
- Anthropic/Meridian: preserve text and thinking content seeded on `content_block_start` in anthropic-messages streams, so `[thinking, text]` replies no longer persist as empty turns or trigger empty-response fallbacks. Fixes #74410. Thanks @vyctorbrzezowski.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildExecEventPrompt,
|
||||
isCronSystemEvent,
|
||||
isExecCompletionEvent,
|
||||
isRelayableExecCompletionEvent,
|
||||
} from "./heartbeat-events-filter.js";
|
||||
|
||||
describe("heartbeat event prompts", () => {
|
||||
@@ -75,6 +76,24 @@ describe("heartbeat event prompts", () => {
|
||||
expected: ["no command output was found", "Reply HEARTBEAT_OK only"],
|
||||
unexpected: ["Please relay the command output to the user", "system messages above"],
|
||||
},
|
||||
{
|
||||
name: "suppresses metadata-only successful exec completions",
|
||||
events: ["Exec completed (abc12345, code 0)"],
|
||||
opts: undefined,
|
||||
expected: ["no command output was found", "Reply HEARTBEAT_OK only"],
|
||||
unexpected: ["Please relay the command output to the user", "abc12345"],
|
||||
},
|
||||
{
|
||||
name: "reports metadata-only failed exec completions without asking for logs",
|
||||
events: ["Exec failed (abc12345, code 1)"],
|
||||
opts: undefined,
|
||||
expected: [
|
||||
"without captured stdout/stderr",
|
||||
"include the exit status or signal",
|
||||
"Do not ask the user to provide missing logs",
|
||||
],
|
||||
unexpected: ["Please relay the command output to the user"],
|
||||
},
|
||||
])("$name", ({ events, opts, expected, unexpected }) => {
|
||||
const prompt = buildExecEventPrompt(events, opts);
|
||||
for (const part of expected) {
|
||||
@@ -98,7 +117,9 @@ describe("heartbeat event classification", () => {
|
||||
{ value: "exec finished: ok", expected: true },
|
||||
{ value: "Exec finished (node=abc, code 0)", expected: true },
|
||||
{ value: "Exec Finished (node=abc, code 1)", expected: true },
|
||||
{ value: "Exec completed (abc12345, code 0)", expected: true },
|
||||
{ value: "Exec completed (abc12345, code 0) :: some output", expected: true },
|
||||
{ value: "Exec failed (abc12345, code 1)", expected: true },
|
||||
{ value: "Exec failed (abc12345, signal SIGTERM) :: error output", expected: true },
|
||||
{ value: "Exec completed (rotate api keys)", expected: false },
|
||||
{ value: "Exec failed: notify me if this happens", expected: false },
|
||||
@@ -119,11 +140,23 @@ describe("heartbeat event classification", () => {
|
||||
{ value: "heartbeat wake: noop", expected: false },
|
||||
{ value: "exec finished: ok", expected: false },
|
||||
{ value: "Exec finished (node=abc, code 0)", expected: false },
|
||||
{ value: "Exec completed (abc12345, code 0)", expected: false },
|
||||
{ value: "Exec completed (abc12345, code 0) :: some output", expected: false },
|
||||
{ value: "Exec failed (abc12345, code 1)", expected: false },
|
||||
{ value: "Exec failed (abc12345, signal SIGTERM) :: error output", expected: false },
|
||||
{ value: "Exec completed (rotate api keys)", expected: true },
|
||||
{ value: "Reminder: if exec failed, notify me", expected: true },
|
||||
])("classifies cron system events for %j", ({ value, expected }) => {
|
||||
expect(isCronSystemEvent(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ value: "Exec completed (abc12345, code 0)", expected: false },
|
||||
{ value: "Exec completed (abc12345, code 0) :: some output", expected: true },
|
||||
{ value: "Exec failed (abc12345, code 1)", expected: true },
|
||||
{ value: "Exec failed (abc12345, signal SIGTERM)", expected: true },
|
||||
{ value: "exec finished: ok", expected: true },
|
||||
])("classifies relayable exec completion events for %j", ({ value, expected }) => {
|
||||
expect(isRelayableExecCompletionEvent(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,71 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
|
||||
const MAX_EXEC_EVENT_PROMPT_CHARS = 8_000;
|
||||
const STRUCTURED_EXEC_COMPLETION_EVENT_RE =
|
||||
/^exec (completed|failed) \(([a-z0-9_-]{1,64}), (code -?\d+|signal [^)]+)\)(?: :: ([\s\S]*))?$/i;
|
||||
|
||||
type StructuredExecCompletionEvent = {
|
||||
raw: string;
|
||||
action: string;
|
||||
id: string;
|
||||
result: string;
|
||||
output: string;
|
||||
succeeded: boolean;
|
||||
};
|
||||
|
||||
function parseStructuredExecCompletionEvent(evt: string): StructuredExecCompletionEvent | null {
|
||||
const trimmed = evt.trim();
|
||||
const match = STRUCTURED_EXEC_COMPLETION_EVENT_RE.exec(trimmed);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const action = match[1] ?? "";
|
||||
const result = match[3] ?? "";
|
||||
return {
|
||||
raw: trimmed,
|
||||
action,
|
||||
id: match[2] ?? "",
|
||||
result,
|
||||
output: (match[4] ?? "").trim(),
|
||||
succeeded: action.toLowerCase() === "completed" && result.toLowerCase() === "code 0",
|
||||
};
|
||||
}
|
||||
|
||||
export function isRelayableExecCompletionEvent(evt: string): boolean {
|
||||
const parsed = parseStructuredExecCompletionEvent(evt);
|
||||
if (!parsed) {
|
||||
return isExecCompletionEvent(evt);
|
||||
}
|
||||
if (parsed.output) {
|
||||
return true;
|
||||
}
|
||||
return !parsed.succeeded;
|
||||
}
|
||||
|
||||
function formatExecEventPromptText(pendingEvents: string[]): {
|
||||
text: string;
|
||||
hasMissingOutputFailure: boolean;
|
||||
} {
|
||||
let hasMissingOutputFailure = false;
|
||||
const lines = pendingEvents.flatMap((event) => {
|
||||
const parsed = parseStructuredExecCompletionEvent(event);
|
||||
if (!parsed) {
|
||||
const trimmed = event.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (parsed.output) {
|
||||
return [parsed.raw];
|
||||
}
|
||||
if (parsed.succeeded) {
|
||||
return [];
|
||||
}
|
||||
hasMissingOutputFailure = true;
|
||||
return [
|
||||
`Exec ${parsed.action} (${parsed.id}, ${parsed.result}) without captured stdout/stderr.`,
|
||||
];
|
||||
});
|
||||
return { text: lines.join("\n").trim(), hasMissingOutputFailure };
|
||||
}
|
||||
|
||||
// Build a dynamic prompt for cron events by embedding the actual event content.
|
||||
// This ensures the model sees the reminder text directly instead of relying on
|
||||
@@ -45,7 +110,7 @@ export function buildExecEventPrompt(
|
||||
opts?: { deliverToUser?: boolean },
|
||||
): string {
|
||||
const deliverToUser = opts?.deliverToUser ?? true;
|
||||
const rawEventText = pendingEvents.join("\n").trim();
|
||||
const { text: rawEventText, hasMissingOutputFailure } = formatExecEventPromptText(pendingEvents);
|
||||
const eventText =
|
||||
rawEventText.length > MAX_EXEC_EVENT_PROMPT_CHARS
|
||||
? `${rawEventText.slice(0, MAX_EXEC_EVENT_PROMPT_CHARS)}\n\n[truncated]`
|
||||
@@ -62,6 +127,15 @@ export function buildExecEventPrompt(
|
||||
"Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output."
|
||||
);
|
||||
}
|
||||
if (hasMissingOutputFailure) {
|
||||
return (
|
||||
"An async command you ran earlier completed without captured stdout/stderr. The completion details are:\n\n" +
|
||||
eventText +
|
||||
"\n\n" +
|
||||
"Tell the user the command completed without captured output and include the exit status or signal. " +
|
||||
"Do not ask the user to provide missing logs, and do not try to retrieve logs from an exec/session id."
|
||||
);
|
||||
}
|
||||
return (
|
||||
"An async command you ran earlier has completed. The command completion details are:\n\n" +
|
||||
eventText +
|
||||
@@ -103,12 +177,11 @@ function isHeartbeatNoiseEvent(evt: string): boolean {
|
||||
}
|
||||
|
||||
export function isExecCompletionEvent(evt: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(evt).trimStart();
|
||||
const trimmed = evt.trimStart();
|
||||
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
return (
|
||||
/^exec finished(?::|\s*\()/.test(normalized) ||
|
||||
/^exec (completed|failed) \([a-z0-9_-]{1,64}, (code -?\d+|signal [^)]+)\)( :: .*)?$/.test(
|
||||
normalized,
|
||||
)
|
||||
STRUCTURED_EXEC_COMPLETION_EVENT_RE.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -550,7 +550,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
expect(options?.messageThreadId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
it("keeps exec-event delivery pinned to the original Telegram topic when session route drifts", async () => {
|
||||
it("keeps output-bearing exec-event delivery pinned to the original Telegram topic when session route drifts", async () => {
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -586,7 +586,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
const getReplySpy = vi.fn().mockResolvedValue({
|
||||
text: "The review-worker spawn finished successfully.",
|
||||
});
|
||||
enqueueSystemEvent("Exec completed (review-run, code 0)", {
|
||||
enqueueSystemEvent("Exec completed (review-run, code 0) :: review-worker spawn finished", {
|
||||
sessionKey,
|
||||
trusted: false,
|
||||
deliveryContext: {
|
||||
@@ -617,6 +617,72 @@ describe("Ghost reminder bug (issue #13317)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses metadata-only successful exec completions", async () => {
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "last",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:47";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "telegram",
|
||||
lastTo: "telegram:-1003774691294:topic:2175",
|
||||
lastThreadId: 2175,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const sendTelegram = vi.fn();
|
||||
const getReplySpy = vi.fn().mockResolvedValue({
|
||||
text: "HEARTBEAT_OK",
|
||||
});
|
||||
enqueueSystemEvent("Exec completed (review-run, code 0)", {
|
||||
sessionKey,
|
||||
trusted: false,
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "telegram:-1003774691294:topic:47",
|
||||
threadId: 47,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
reason: "exec-event",
|
||||
deps: {
|
||||
getReplyFromConfig: getReplySpy,
|
||||
telegram: sendTelegram,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(getReplySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Body: expect.stringContaining("no command output was found"),
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(sendTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Telegram topic routing for isolated scheduled heartbeats", async () => {
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg = createLastTargetConfig({ tmpDir, storePath, isolatedSession: true });
|
||||
|
||||
@@ -75,10 +75,11 @@ import { loadOrCreateDeviceIdentity } from "./device-identity.js";
|
||||
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
|
||||
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
|
||||
import {
|
||||
buildExecEventPrompt,
|
||||
buildCronEventPrompt,
|
||||
buildExecEventPrompt,
|
||||
isCronSystemEvent,
|
||||
isExecCompletionEvent,
|
||||
isRelayableExecCompletionEvent,
|
||||
} from "./heartbeat-events-filter.js";
|
||||
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
|
||||
import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js";
|
||||
@@ -683,6 +684,7 @@ async function resolveHeartbeatPreflight(params: {
|
||||
type HeartbeatPromptResolution = {
|
||||
prompt: string | null;
|
||||
hasExecCompletion: boolean;
|
||||
hasRelayableExecCompletion: boolean;
|
||||
hasCronEvents: boolean;
|
||||
};
|
||||
|
||||
@@ -755,6 +757,8 @@ function resolveHeartbeatRunPrompt(params: {
|
||||
.map((event) => event.text)
|
||||
: [];
|
||||
const hasExecCompletion = execEvents.length > 0;
|
||||
const hasRelayableExecCompletion =
|
||||
params.canRelayToUser && execEvents.some((event) => isRelayableExecCompletionEvent(event));
|
||||
const hasCronEvents = cronEvents.length > 0;
|
||||
|
||||
if (params.preflight.tasks && params.preflight.tasks.length > 0) {
|
||||
@@ -781,9 +785,19 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
|
||||
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
|
||||
}
|
||||
}
|
||||
return { prompt, hasExecCompletion: false, hasCronEvents: false };
|
||||
return {
|
||||
prompt,
|
||||
hasExecCompletion: false,
|
||||
hasRelayableExecCompletion: false,
|
||||
hasCronEvents: false,
|
||||
};
|
||||
}
|
||||
return { prompt: null, hasExecCompletion: false, hasCronEvents: false };
|
||||
return {
|
||||
prompt: null,
|
||||
hasExecCompletion: false,
|
||||
hasRelayableExecCompletion: false,
|
||||
hasCronEvents: false,
|
||||
};
|
||||
}
|
||||
|
||||
const basePrompt = hasExecCompletion
|
||||
@@ -793,7 +807,7 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
|
||||
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
|
||||
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
|
||||
|
||||
return { prompt, hasExecCompletion, hasCronEvents };
|
||||
return { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents };
|
||||
}
|
||||
|
||||
export async function runHeartbeatOnce(opts: {
|
||||
@@ -931,15 +945,16 @@ export async function runHeartbeatOnce(opts: {
|
||||
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
|
||||
);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({
|
||||
cfg,
|
||||
heartbeat,
|
||||
preflight,
|
||||
canRelayToUser,
|
||||
workspaceDir,
|
||||
startedAt,
|
||||
heartbeatFileContent: preflight.heartbeatFileContent,
|
||||
});
|
||||
const { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents } =
|
||||
resolveHeartbeatRunPrompt({
|
||||
cfg,
|
||||
heartbeat,
|
||||
preflight,
|
||||
canRelayToUser,
|
||||
workspaceDir,
|
||||
startedAt,
|
||||
heartbeatFileContent: preflight.heartbeatFileContent,
|
||||
});
|
||||
|
||||
// If no tasks are due, skip heartbeat entirely
|
||||
if (prompt === null) {
|
||||
@@ -1202,14 +1217,15 @@ export async function runHeartbeatOnce(opts: {
|
||||
// Also, if normalized.text is empty due to token stripping but we have exec completion,
|
||||
// fall back to the original reply text.
|
||||
const execFallbackText =
|
||||
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
|
||||
hasRelayableExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
|
||||
? replyPayload.text.trim()
|
||||
: null;
|
||||
if (execFallbackText) {
|
||||
normalized.text = execFallbackText;
|
||||
normalized.shouldSkip = false;
|
||||
}
|
||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
|
||||
const shouldSkipMain =
|
||||
normalized.shouldSkip && !normalized.hasMedia && !hasRelayableExecCompletion;
|
||||
if (shouldSkipMain && reasoningPayloads.length === 0) {
|
||||
await restoreHeartbeatUpdatedAt({
|
||||
storePath,
|
||||
|
||||
Reference in New Issue
Block a user