Skip to content

Commit 2ab3a4e

Browse files
fuller-stack-devclawsweeper[bot]Takhoffman
authored
Filter heartbeat response-tool transcript artifacts (#83477)
Summary: - This PR replaces pair-only heartbeat filtering with span-based filtering before embedded-runner prompt assem ... ession coverage and a changelog entry, and updates the LINE command type to use the SDK command definition. - Reproducibility: yes. from source and report evidence: current main only removes immediate heartbeat prompt/ ... body supplies same-session terminal proof and a commenter supplied a matching Discord gateway observation. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: filter heartbeat transcript artifacts - PR branch already contained follow-up commit before automerge: fix: clean up heartbeat filter lint - PR branch already contained follow-up commit before automerge: fix: keep line entry on channel SDK - PR branch already contained follow-up commit before automerge: fix: filter heartbeat response text transcript shapes - PR branch already contained follow-up commit before automerge: Filter heartbeat response-tool transcript artifacts Validation: - ClawSweeper review passed for head e019c74. - Required merge gates passed before the squash merge. Prepared head SHA: e019c74 Review: #83477 (comment) Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 5d799c2 commit 2ab3a4e

7 files changed

Lines changed: 1547 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.
1415
- Agents/code mode: spell out the `exec` tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.
1516
- Codex: give `image_generate` dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.
1617
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.

extensions/line/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {
22
defineBundledChannelEntry,
3+
type OpenClawPluginCommandDefinition,
34
type OpenClawPluginApi,
45
} from "openclaw/plugin-sdk/channel-entry-contract";
56

6-
type RegisteredLineCardCommand = Parameters<OpenClawPluginApi["registerCommand"]>[0];
7+
type RegisteredLineCardCommand = OpenClawPluginCommandDefinition;
78

89
let lineCardCommandPromise: Promise<RegisteredLineCardCommand> | null = null;
910

src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from "node:os";
33
import path from "node:path";
44
import type { AgentMessage } from "@earendil-works/pi-agent-core";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../../../auto-reply/heartbeat.js";
67
import type { OpenClawConfig } from "../../../config/types.js";
78
import { buildMemorySystemPromptAddition } from "../../../context-engine/delegate.js";
89
import {
@@ -392,6 +393,294 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
392393
}
393394
});
394395

396+
it("filters heartbeat response-tool transcript artifacts before normal prompt snapshots", async () => {
397+
const contextEngine = createContextEngineBootstrapAndAssemble();
398+
const sessionMessages = [
399+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT, timestamp: 1 },
400+
{
401+
role: "assistant",
402+
content: [
403+
{
404+
type: "toolCall",
405+
id: "call_bash",
406+
name: "bash",
407+
arguments: { command: "cat HEARTBEAT.md" },
408+
},
409+
],
410+
timestamp: 2,
411+
},
412+
{
413+
role: "toolResult",
414+
toolCallId: "call_bash",
415+
content: [{ type: "text", text: "HEARTBEAT.md says stay quiet" }],
416+
timestamp: 3,
417+
},
418+
{
419+
role: "assistant",
420+
content: [
421+
{
422+
type: "toolCall",
423+
id: "call_heartbeat",
424+
name: "heartbeat_respond",
425+
arguments: {
426+
outcome: "no_change",
427+
notify: false,
428+
summary: "No visible update.",
429+
},
430+
},
431+
],
432+
timestamp: 4,
433+
},
434+
{
435+
role: "toolResult",
436+
toolCallId: "call_heartbeat",
437+
content: [{ type: "text", text: '{"notify":false}' }],
438+
timestamp: 5,
439+
},
440+
{ role: "assistant", content: "No visible update. notify=false", timestamp: 6 },
441+
] as AgentMessage[];
442+
443+
const result = await createContextEngineAttemptRunner({
444+
contextEngine,
445+
sessionKey,
446+
tempPaths,
447+
sessionMessages,
448+
attemptOverrides: {
449+
prompt: "what model are you",
450+
transcriptPrompt: "what model are you",
451+
},
452+
sessionPrompt: async (session) => {
453+
session.messages = [
454+
...session.messages,
455+
{ role: "assistant", content: "gpt-test", timestamp: 7 },
456+
];
457+
},
458+
});
459+
460+
const assembleInput = contextEngine.assemble.mock.calls.at(0)?.[0];
461+
const assembledMessagesJson = JSON.stringify(assembleInput?.messages ?? []);
462+
const snapshotJson = JSON.stringify(result.messagesSnapshot);
463+
for (const artifact of [
464+
"HEARTBEAT.md",
465+
"heartbeat_respond",
466+
"notify=false",
467+
'"notify":false',
468+
HEARTBEAT_TRANSCRIPT_PROMPT,
469+
]) {
470+
expect(assembledMessagesJson).not.toContain(artifact);
471+
expect(snapshotJson).not.toContain(artifact);
472+
}
473+
expect(result.finalPromptText).toBe("what model are you");
474+
});
475+
476+
it("filters interrupted prompt-only heartbeat artifacts before normal prompt snapshots", async () => {
477+
const contextEngine = createContextEngineBootstrapAndAssemble();
478+
const sessionMessages = [
479+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT, timestamp: 1 },
480+
] as AgentMessage[];
481+
482+
const result = await createContextEngineAttemptRunner({
483+
contextEngine,
484+
sessionKey,
485+
tempPaths,
486+
sessionMessages,
487+
attemptOverrides: {
488+
prompt: "what model are you",
489+
transcriptPrompt: "what model are you",
490+
},
491+
sessionPrompt: async (session) => {
492+
session.messages = [
493+
...session.messages,
494+
{ role: "assistant", content: "gpt-test", timestamp: 2 },
495+
];
496+
},
497+
});
498+
499+
const assembleInput = contextEngine.assemble.mock.calls.at(0)?.[0];
500+
const assembledMessagesJson = JSON.stringify(assembleInput?.messages ?? []);
501+
const snapshotJson = JSON.stringify(result.messagesSnapshot);
502+
expect(assembledMessagesJson).not.toContain(HEARTBEAT_TRANSCRIPT_PROMPT);
503+
expect(snapshotJson).not.toContain(HEARTBEAT_TRANSCRIPT_PROMPT);
504+
expect(result.finalPromptText).toBe("what model are you");
505+
});
506+
507+
it("filters pending notify=true heartbeat response-tool calls before normal prompt snapshots", async () => {
508+
const contextEngine = createContextEngineBootstrapAndAssemble();
509+
const sessionMessages = [
510+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT, timestamp: 1 },
511+
{
512+
role: "assistant",
513+
content: [
514+
{
515+
type: "toolCall",
516+
id: "call_heartbeat",
517+
name: "heartbeat_respond",
518+
arguments: {
519+
outcome: "needs_attention",
520+
notify: true,
521+
summary: "Build is blocked.",
522+
notificationText: "Build is blocked on missing credentials.",
523+
},
524+
},
525+
],
526+
timestamp: 2,
527+
},
528+
] as AgentMessage[];
529+
530+
const result = await createContextEngineAttemptRunner({
531+
contextEngine,
532+
sessionKey,
533+
tempPaths,
534+
sessionMessages,
535+
attemptOverrides: {
536+
prompt: "what model are you",
537+
transcriptPrompt: "what model are you",
538+
},
539+
sessionPrompt: async (session) => {
540+
session.messages = [
541+
...session.messages,
542+
{ role: "assistant", content: "gpt-test", timestamp: 3 },
543+
];
544+
},
545+
});
546+
547+
const assembleInput = contextEngine.assemble.mock.calls.at(0)?.[0];
548+
const assembledMessagesJson = JSON.stringify(assembleInput?.messages ?? []);
549+
const snapshotJson = JSON.stringify(result.messagesSnapshot);
550+
for (const artifact of [
551+
HEARTBEAT_TRANSCRIPT_PROMPT,
552+
"heartbeat_respond",
553+
'"notify":true',
554+
"Build is blocked on missing credentials.",
555+
]) {
556+
expect(assembledMessagesJson).not.toContain(artifact);
557+
expect(snapshotJson).not.toContain(artifact);
558+
}
559+
expect(result.finalPromptText).toBe("what model are you");
560+
});
561+
562+
it("preserves visible heartbeat alerts in normal prompt snapshots", async () => {
563+
const contextEngine = createContextEngineBootstrapAndAssemble();
564+
const sessionMessages = [
565+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT, timestamp: 1 },
566+
{
567+
role: "assistant",
568+
content: [
569+
{
570+
type: "toolCall",
571+
id: "call_bash",
572+
name: "bash",
573+
arguments: { command: "cat HEARTBEAT.md" },
574+
},
575+
],
576+
timestamp: 2,
577+
},
578+
{
579+
role: "toolResult",
580+
toolCallId: "call_bash",
581+
content: [{ type: "text", text: "HEARTBEAT.md says check deployment" }],
582+
timestamp: 3,
583+
},
584+
{
585+
role: "assistant",
586+
content: "Build is blocked on a failing release check.",
587+
timestamp: 4,
588+
},
589+
] as AgentMessage[];
590+
591+
const result = await createContextEngineAttemptRunner({
592+
contextEngine,
593+
sessionKey,
594+
tempPaths,
595+
sessionMessages,
596+
attemptOverrides: {
597+
prompt: "what changed while I was away?",
598+
transcriptPrompt: "what changed while I was away?",
599+
},
600+
sessionPrompt: async (session) => {
601+
session.messages = [
602+
...session.messages,
603+
{ role: "assistant", content: "gpt-test", timestamp: 5 },
604+
];
605+
},
606+
});
607+
608+
const assembleInput = contextEngine.assemble.mock.calls.at(0)?.[0];
609+
const assembledMessagesJson = JSON.stringify(assembleInput?.messages ?? []);
610+
const snapshotJson = JSON.stringify(result.messagesSnapshot);
611+
for (const visibleContext of [
612+
HEARTBEAT_TRANSCRIPT_PROMPT,
613+
"HEARTBEAT.md says check deployment",
614+
"Build is blocked on a failing release check.",
615+
]) {
616+
expect(assembledMessagesJson).toContain(visibleContext);
617+
expect(snapshotJson).toContain(visibleContext);
618+
}
619+
expect(result.finalPromptText).toBe("what changed while I was away?");
620+
});
621+
622+
it("preserves visible heartbeat response-tool notifications in normal prompt snapshots", async () => {
623+
const contextEngine = createContextEngineBootstrapAndAssemble();
624+
const sessionMessages = [
625+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT, timestamp: 1 },
626+
{
627+
role: "assistant",
628+
content: [
629+
{
630+
type: "toolCall",
631+
id: "call_heartbeat",
632+
name: "heartbeat_respond",
633+
arguments: {
634+
outcome: "needs_attention",
635+
notify: true,
636+
summary: "Build is blocked.",
637+
notificationText: "Build is blocked on missing credentials.",
638+
},
639+
},
640+
],
641+
timestamp: 2,
642+
},
643+
{
644+
role: "toolResult",
645+
toolCallId: "call_heartbeat",
646+
content: [{ type: "text", text: '{"notify":true}' }],
647+
timestamp: 3,
648+
},
649+
{ role: "assistant", content: "HEARTBEAT_OK", timestamp: 4 },
650+
] as AgentMessage[];
651+
652+
const result = await createContextEngineAttemptRunner({
653+
contextEngine,
654+
sessionKey,
655+
tempPaths,
656+
sessionMessages,
657+
attemptOverrides: {
658+
prompt: "what changed while I was away?",
659+
transcriptPrompt: "what changed while I was away?",
660+
},
661+
sessionPrompt: async (session) => {
662+
session.messages = [
663+
...session.messages,
664+
{ role: "assistant", content: "gpt-test", timestamp: 5 },
665+
];
666+
},
667+
});
668+
669+
const assembleInput = contextEngine.assemble.mock.calls.at(0)?.[0];
670+
const assembledMessagesJson = JSON.stringify(assembleInput?.messages ?? []);
671+
const snapshotJson = JSON.stringify(result.messagesSnapshot);
672+
for (const visibleContext of [
673+
"heartbeat_respond",
674+
'"notify":true',
675+
"Build is blocked on missing credentials.",
676+
"HEARTBEAT_OK",
677+
]) {
678+
expect(assembledMessagesJson).toContain(visibleContext);
679+
expect(snapshotJson).toContain(visibleContext);
680+
}
681+
expect(result.finalPromptText).toBe("what changed while I was away?");
682+
});
683+
395684
it("rebuilds skill prompt inputs from the sandbox workspace for non-rw sandbox runs", async () => {
396685
const sandboxWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-skills-"));
397686
tempPaths.push(sandboxWorkspace);

src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3-
import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js";
3+
import { filterHeartbeatTranscriptArtifacts } from "../../../auto-reply/heartbeat-filter.js";
44
import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js";
55
import { limitHistoryTurns } from "../history.js";
66
import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js";
@@ -208,7 +208,11 @@ describe("embedded attempt context injection", () => {
208208
{ role: "assistant", content: "HEARTBEAT_OK", timestamp: 4 } as unknown as AgentMessage,
209209
];
210210

211-
const heartbeatFiltered = filterHeartbeatPairs(sessionMessages, undefined, HEARTBEAT_PROMPT);
211+
const heartbeatFiltered = filterHeartbeatTranscriptArtifacts(
212+
sessionMessages,
213+
undefined,
214+
HEARTBEAT_PROMPT,
215+
);
212216
const limited = limitHistoryTurns(heartbeatFiltered, 1);
213217
await assembleAttemptContextEngine({
214218
contextEngine: {

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
66
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
77
import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js";
88
import { buildHierarchyReinforcementMessage } from "../../../auto-reply/handoff-summarizer.js";
9-
import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js";
9+
import { filterHeartbeatTranscriptArtifacts } from "../../../auto-reply/heartbeat-filter.js";
1010
import { stripInboundMetadata } from "../../../auto-reply/reply/strip-inbound-meta.js";
1111
import { getRuntimeConfig } from "../../../config/config.js";
1212
import { resolveStorePath } from "../../../config/sessions/paths.js";
@@ -3033,7 +3033,7 @@ export async function runEmbeddedAttempt(
30333033
params.config && sessionAgentId
30343034
? resolveHeartbeatSummaryForAgent(params.config, sessionAgentId)
30353035
: undefined;
3036-
const heartbeatFiltered = filterHeartbeatPairs(
3036+
const heartbeatFiltered = filterHeartbeatTranscriptArtifacts(
30373037
validated,
30383038
heartbeatSummary?.ackMaxChars,
30393039
heartbeatSummary?.prompt,
@@ -3052,7 +3052,7 @@ export async function runEmbeddedAttempt(
30523052
})
30533053
: truncated;
30543054
cacheTrace?.recordStage("session:limited", { messages: limited });
3055-
if (limited.length > 0) {
3055+
if (limited.length > 0 || prior.length > 0) {
30563056
activeSession.agent.state.messages = limited;
30573057
}
30583058
}
@@ -3627,7 +3627,7 @@ export async function runEmbeddedAttempt(
36273627
: undefined;
36283628

36293629
try {
3630-
const filteredMessages = filterHeartbeatPairs(
3630+
const filteredMessages = filterHeartbeatTranscriptArtifacts(
36313631
activeSession.messages,
36323632
heartbeatSummary?.ackMaxChars,
36333633
heartbeatSummary?.prompt,

0 commit comments

Comments
 (0)