Skip to content

Commit 185278e

Browse files
wlff123ruvnet
authored andcommitted
afterTurn: role-preserving message storage + heartbeat filter (volcengine#1340 hand-merged)
Cherry-picked upstream 68e4d89 with manual conflict resolution against our prior plugin commit acbb7f9 (pinned context injection, recall refactor). Three changes from volcengine#1340: 1. Role-preserving message storage (the main feature): Instead of collapsing entire turns into a single 'user' addSessionMessage call, iterate turnMessages and group adjacent same-role messages by actual role (user/assistant). Sends one addSessionMessage per group, preserving conversation structure for OpenViking. Replaces the previous join-everything-as-user logic. 2. Heartbeat filters (defense in depth, not replacement): - Session-level: 'if (afterTurnParams.isHeartbeat) return;' early-return when the calling code flags the whole turn as a heartbeat. - Per-message: HEARTBEAT_RE = /\bHEARTBEAT(?:\.md|_OK)\b/ drops individual heartbeat messages within a turn. These are narrow (uppercase HEARTBEAT only) and complement — not replace — our broader server-side trivial filter at openviking/session/session.py _is_trivial_session, which catches lowercase heartbeat/health_check/ping for ANY ingest path (CLI, eval, future clients), not just the plugin. 3. extractSingleMessageText helper in text-utils.ts (additive). Hand-merge decisions vs upstream: - Imports collision: kept both extractLastAssistantText (ours) and extractSingleMessageText (volcengine#1340). - Backoff guard order: heartbeat early-return placed BEFORE the consecutive- failure backoff guard so heartbeats never increment failure counters. - addSessionMessage call arity fix: volcengine#1340's loop calls 5-arg form (id, role, content, agentId, createdAt) which would put agentId in our parts? slot. Updated to 6-arg form (id, role, content, undefined, agentId, createdAt) matching client.ts signature. - Inline commit block at the end of volcengine#1340: dropped. Our architecture routes commits through doCommitOVSession (separate function with alignment + drift detection hooks). Reintroducing the inline commit would conflict with that routing. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 19d63ee commit 185278e

File tree

3 files changed

+227
-20
lines changed

3 files changed

+227
-20
lines changed

examples/openclaw-plugin/context-engine.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getCaptureDecision,
1111
extractNewTurnTexts,
1212
extractLastAssistantText,
13+
extractSingleMessageText,
1314
shouldBypassSession,
1415
} from "./text-utils.js";
1516
import {
@@ -812,6 +813,11 @@ export function createMemoryOpenVikingContextEngine(params: {
812813
return;
813814
}
814815

816+
// Heartbeat session — skip entirely (from upstream #1340)
817+
if (afterTurnParams.isHeartbeat) {
818+
return;
819+
}
820+
815821
// Exponential backoff after consecutive failures
816822
if (_consecutiveCaptureFailures >= MAX_CONSECUTIVE_FAILURES) {
817823
const backoffMs = BACKOFF_BASE_MS * Math.pow(2, _consecutiveCaptureFailures - MAX_CONSECUTIVE_FAILURES);
@@ -932,19 +938,42 @@ export function createMemoryOpenVikingContextEngine(params: {
932938
return;
933939
}
934940
const client = await getClient();
935-
const turnText = newTexts.join("\n");
936-
const sanitized = turnText.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim();
937941
const createdAt = pickLatestCreatedAt(turnMessages);
938942

939-
if (sanitized) {
940-
await client.addSessionMessage(OVSessionId, "user", sanitized, undefined, agentId, createdAt);
941-
} else {
942-
diag("afterTurn_skip", OVSessionId, {
943-
reason: "sanitized_empty",
944-
});
943+
// Group by OV role (user|assistant), merge adjacent same-role.
944+
// Drops heartbeat messages and strips <relevant-memories> tags from
945+
// user content. From upstream #1340 — preserves conversation
946+
// structure when sending to OpenViking instead of collapsing the
947+
// turn into a single "user" message.
948+
const HEARTBEAT_RE = /\bHEARTBEAT(?:\.md|_OK)\b/;
949+
const groups: Array<{ role: "user" | "assistant"; texts: string[] }> = [];
950+
for (const msg of turnMessages) {
951+
const text = extractSingleMessageText(msg);
952+
if (!text) continue;
953+
if (HEARTBEAT_RE.test(text)) continue;
954+
const role = (msg as Record<string, unknown>).role as string;
955+
const ovRole: "user" | "assistant" = role === "assistant" ? "assistant" : "user";
956+
const content = ovRole === "user"
957+
? text.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim()
958+
: text;
959+
if (!content) continue;
960+
const last = groups[groups.length - 1];
961+
if (last && last.role === ovRole) {
962+
last.texts.push(content);
963+
} else {
964+
groups.push({ role: ovRole, texts: [content] });
965+
}
966+
}
967+
968+
if (groups.length === 0) {
969+
diag("afterTurn_skip", OVSessionId, { reason: "sanitized_empty" });
945970
return;
946971
}
947972

973+
for (const group of groups) {
974+
await client.addSessionMessage(OVSessionId, group.role, group.texts.join("\n"), undefined, agentId, createdAt);
975+
}
976+
948977
const session = await client.getSession(OVSessionId, agentId);
949978
const pendingTokens = session.pending_tokens ?? 0;
950979

examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe("context-engine afterTurn()", () => {
182182
);
183183
});
184184

185-
it("stores new messages via addSessionMessage", async () => {
185+
it("stores new messages via addSessionMessage with proper roles", async () => {
186186
const { engine, client } = makeEngine();
187187

188188
const messages = [
@@ -198,10 +198,13 @@ describe("context-engine afterTurn()", () => {
198198
prePromptMessageCount: 1,
199199
});
200200

201-
expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
202-
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
203-
expect(storedContent).toContain("hello world");
204-
expect(storedContent).toContain("hi there");
201+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
202+
// First call: user message
203+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
204+
expect(client.addSessionMessage.mock.calls[0][2]).toContain("hello world");
205+
// Second call: assistant message
206+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("assistant");
207+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("hi there");
205208
});
206209

207210
it("passes the latest non-system message timestamp to addSessionMessage as ISO string", async () => {
@@ -220,12 +223,14 @@ describe("context-engine afterTurn()", () => {
220223
prePromptMessageCount: 1,
221224
});
222225

223-
expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
224-
const createdAt = client.addSessionMessage.mock.calls[0][4] as string;
226+
// user + assistant + toolResult(→user) = 3 calls (toolResult merges with no adjacent user)
227+
expect(client.addSessionMessage).toHaveBeenCalled();
228+
const lastCallIdx = client.addSessionMessage.mock.calls.length - 1;
229+
const createdAt = client.addSessionMessage.mock.calls[lastCallIdx][4] as string;
225230
expect(createdAt).toBe("2026-04-01T10:03:00.000Z");
226231
});
227232

228-
it("sanitizes <relevant-memories> from stored content", async () => {
233+
it("sanitizes <relevant-memories> from user content but not from assistant", async () => {
229234
const { engine, client } = makeEngine();
230235

231236
const messages = [
@@ -243,6 +248,7 @@ describe("context-engine afterTurn()", () => {
243248
});
244249

245250
expect(client.addSessionMessage).toHaveBeenCalledTimes(1);
251+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
246252
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
247253
expect(storedContent).not.toContain("relevant-memories");
248254
expect(storedContent).not.toContain("injected memory data");
@@ -391,10 +397,12 @@ describe("context-engine afterTurn()", () => {
391397
prePromptMessageCount: 0,
392398
});
393399

394-
const storedContent = client.addSessionMessage.mock.calls[0][2] as string;
395-
expect(storedContent).toContain("src/app.ts");
396-
expect(storedContent).toContain("npm install");
397-
expect(storedContent).toContain("export const x = 1");
400+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
401+
const userContent = client.addSessionMessage.mock.calls[0][2] as string;
402+
const assistantContent = client.addSessionMessage.mock.calls[1][2] as string;
403+
expect(userContent).toContain("src/app.ts");
404+
expect(userContent).toContain("npm install");
405+
expect(assistantContent).toContain("export const x = 1");
398406
});
399407

400408
it("passes agentId to addSessionMessage", async () => {
@@ -428,6 +436,143 @@ describe("context-engine afterTurn()", () => {
428436
expect(client.getSession).toHaveBeenCalled();
429437
});
430438

439+
it("maps toolResult to user role", async () => {
440+
const { engine, client } = makeEngine();
441+
442+
const messages = [
443+
{ role: "assistant", content: [
444+
{ type: "text", text: "running tool" },
445+
{ type: "toolUse", name: "bash", input: { cmd: "ls" } },
446+
] },
447+
{ role: "toolResult", toolName: "bash", content: "file1.txt\nfile2.txt" },
448+
{ role: "assistant", content: "done" },
449+
];
450+
451+
await engine.afterTurn!({
452+
sessionId: "s1",
453+
sessionFile: "",
454+
messages,
455+
prePromptMessageCount: 0,
456+
});
457+
458+
expect(client.addSessionMessage).toHaveBeenCalledTimes(3);
459+
// assistant → user(toolResult) → assistant
460+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("assistant");
461+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("user");
462+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[bash result]:");
463+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("file1.txt");
464+
expect(client.addSessionMessage.mock.calls[2][1]).toBe("assistant");
465+
});
466+
467+
it("merges adjacent same-role messages", async () => {
468+
const { engine, client } = makeEngine();
469+
470+
const messages = [
471+
{ role: "user", content: "first question" },
472+
{ role: "user", content: "second question" },
473+
{ role: "assistant", content: "answer" },
474+
];
475+
476+
await engine.afterTurn!({
477+
sessionId: "s1",
478+
sessionFile: "",
479+
messages,
480+
prePromptMessageCount: 0,
481+
});
482+
483+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
484+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("user");
485+
expect(client.addSessionMessage.mock.calls[0][2]).toContain("first question");
486+
expect(client.addSessionMessage.mock.calls[0][2]).toContain("second question");
487+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("assistant");
488+
});
489+
490+
it("merges adjacent toolResults into one user group", async () => {
491+
const { engine, client } = makeEngine();
492+
493+
const messages = [
494+
{ role: "assistant", content: [
495+
{ type: "text", text: "calling tools" },
496+
{ type: "toolUse", name: "read", input: { path: "a.txt" } },
497+
] },
498+
{ role: "toolResult", toolName: "read", content: "content of a" },
499+
{ role: "toolResult", toolName: "write", content: "ok" },
500+
{ role: "assistant", content: "all done" },
501+
];
502+
503+
await engine.afterTurn!({
504+
sessionId: "s1",
505+
sessionFile: "",
506+
messages,
507+
prePromptMessageCount: 0,
508+
});
509+
510+
expect(client.addSessionMessage).toHaveBeenCalledTimes(3);
511+
expect(client.addSessionMessage.mock.calls[0][1]).toBe("assistant");
512+
// Two toolResults merged into one user call
513+
expect(client.addSessionMessage.mock.calls[1][1]).toBe("user");
514+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[read result]:");
515+
expect(client.addSessionMessage.mock.calls[1][2]).toContain("[write result]:");
516+
expect(client.addSessionMessage.mock.calls[2][1]).toBe("assistant");
517+
});
518+
519+
it("does not sanitize <relevant-memories> from assistant content", async () => {
520+
const { engine, client } = makeEngine();
521+
522+
const messages = [
523+
{ role: "user", content: "question" },
524+
{ role: "assistant", content: "Here is context <relevant-memories>data</relevant-memories> end" },
525+
];
526+
527+
await engine.afterTurn!({
528+
sessionId: "s1",
529+
sessionFile: "",
530+
messages,
531+
prePromptMessageCount: 0,
532+
});
533+
534+
expect(client.addSessionMessage).toHaveBeenCalledTimes(2);
535+
const assistantContent = client.addSessionMessage.mock.calls[1][2] as string;
536+
expect(assistantContent).toContain("relevant-memories");
537+
});
538+
539+
it("skips heartbeat messages from being stored", async () => {
540+
const { engine, client } = makeEngine();
541+
542+
const messages = [
543+
{ role: "user", content: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK." },
544+
{ role: "assistant", content: "HEARTBEAT_OK" },
545+
];
546+
547+
await engine.afterTurn!({
548+
sessionId: "s1",
549+
sessionFile: "",
550+
messages,
551+
prePromptMessageCount: 0,
552+
});
553+
554+
expect(client.addSessionMessage).not.toHaveBeenCalled();
555+
});
556+
557+
it("skips heartbeat via isHeartbeat flag", async () => {
558+
const { engine, client } = makeEngine();
559+
560+
const messages = [
561+
{ role: "user", content: "regular message" },
562+
{ role: "assistant", content: "reply" },
563+
];
564+
565+
await engine.afterTurn!({
566+
sessionId: "s1",
567+
sessionFile: "",
568+
messages,
569+
prePromptMessageCount: 0,
570+
isHeartbeat: true,
571+
});
572+
573+
expect(client.addSessionMessage).not.toHaveBeenCalled();
574+
});
575+
431576
it("skips store when all new messages are system only", async () => {
432577
const { engine, client } = makeEngine();
433578

examples/openclaw-plugin/text-utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,39 @@ function formatToolResultContent(content: unknown): string {
422422
return "";
423423
}
424424

425+
/**
426+
* Extract text from a single message without a `[role]:` prefix.
427+
* Used by afterTurn to send messages with their actual role.
428+
*/
429+
export function extractSingleMessageText(msg: unknown): string {
430+
if (!msg || typeof msg !== "object") return "";
431+
const m = msg as Record<string, unknown>;
432+
const role = m.role as string;
433+
if (!role || role === "system") return "";
434+
435+
if (role === "toolResult") {
436+
const toolName = typeof m.toolName === "string" ? m.toolName : "tool";
437+
const resultText = formatToolResultContent(m.content);
438+
return resultText ? `[${toolName} result]: ${resultText}` : "";
439+
}
440+
441+
const content = m.content;
442+
if (typeof content === "string") return content.trim();
443+
if (Array.isArray(content)) {
444+
const parts: string[] = [];
445+
for (const block of content) {
446+
const b = block as Record<string, unknown>;
447+
if (b?.type === "text" && typeof b.text === "string") {
448+
parts.push((b.text as string).trim());
449+
} else if (b?.type === "toolUse") {
450+
parts.push(formatToolUseBlock(b));
451+
}
452+
}
453+
return parts.join("\n");
454+
}
455+
return "";
456+
}
457+
425458
/**
426459
* 提取从 startIndex 开始的新消息(user + assistant + toolResult),返回格式化的文本。
427460
* 保留 toolUse 完整内容(tool name + input)和 toolResult 完整内容,

0 commit comments

Comments
 (0)