From 6777bf0ecce845616439705749e877817b5421bd Mon Sep 17 00:00:00 2001 From: Aldous Date: Thu, 9 Apr 2026 12:12:40 -0400 Subject: [PATCH] fix(openclaw): derive retain turns from conversation history --- .../openclaw/src/index.test.ts | 136 +++++++++++++++++- hindsight-integrations/openclaw/src/index.ts | 57 ++++++-- 2 files changed, 180 insertions(+), 13 deletions(-) diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index f395d4878..82418763e 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { +import openclawPlugin, { stripMemoryTags, extractRecallQuery, formatMemories, prepareRetentionTranscript, + countUserTurns, + getRetentionTurnIndex, sliceLastTurnsByUserBoundary, composeRecallQuery, truncateRecallQuery, @@ -229,9 +231,36 @@ describe('formatMemories', () => { }); // --------------------------------------------------------------------------- -// prepareRetentionTranscript +// retention helpers // --------------------------------------------------------------------------- +describe('countUserTurns', () => { + it('counts user messages across a resumed conversation history', () => { + expect(countUserTurns([ + { role: 'user', content: 'turn 1' }, + { role: 'assistant', content: 'reply 1' }, + { role: 'system', content: 'meta' }, + { role: 'user', content: 'turn 2' }, + { role: 'assistant', content: 'reply 2' }, + { role: 'user', content: 'turn 3' }, + ])).toBe(3); + }); +}); + +describe('getRetentionTurnIndex', () => { + it('uses the full conversation turn count for per-turn retention', () => { + expect(getRetentionTurnIndex(7, 1)).toBe(7); + }); + + it('derives a stable window sequence for chunked retention', () => { + expect(getRetentionTurnIndex(6, 3)).toBe(2); + }); + + it('returns null when a chunk boundary has not been reached', () => { + expect(getRetentionTurnIndex(5, 3)).toBeNull(); + }); +}); + describe('buildRetainRequest', () => { it('adds configured source metadata and retain tags', () => { const request = buildRetainRequest('hello world', 2, { @@ -313,6 +342,21 @@ describe('buildRetainRequest', () => { sender_id: '12345', }); }); + + it('supports resumed conversations by accepting an explicit later turn index', () => { + const request = buildRetainRequest('hello world', 2, { + agentId: 'main', + sessionKey: 'agent:main:discord:channel:123', + messageProvider: 'discord', + channelId: 'channel:123', + senderId: 'user:456', + }, { + retainSource: 'openclaw', + }, 1700000000000, { turnIndex: 7 }); + + expect(request.documentId).toBe('openclaw:agent:main:discord:channel:123:turn:000007'); + expect(request.metadata?.turn_index).toBe('7'); + }); }); // --------------------------------------------------------------------------- @@ -754,6 +798,94 @@ describe('session identity helpers', () => { // waitForReady — CLI mode no-op (initPromise is null before service.start()) // --------------------------------------------------------------------------- +describe('agent_end retention hook', () => { + it('uses resumed conversation history to number retained turns', async () => { + const handlers = new Map Promise>(); + const api = { + config: { + plugins: { + entries: { + hindsight: { + config: { + autoRecall: false, + autoRetain: true, + dynamicBankId: true, + }, + }, + }, + }, + }, + registerService: () => {}, + on: (event: string, handler: (event: any, ctx?: any) => Promise) => { + handlers.set(event, handler); + }, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }; + + openclawPlugin(api as any); + const agentEnd = handlers.get('agent_end'); + expect(agentEnd).toBeDefined(); + + const retained: any[] = []; + const hindsight = (global as any).__hindsightClient; + const originalWaitForReady = hindsight.waitForReady; + const originalGetClientForContext = hindsight.getClientForContext; + + hindsight.waitForReady = async () => {}; + hindsight.getClientForContext = async () => ({ + retain: async (request: any) => { + retained.push(request); + }, + }); + + try { + await agentEnd?.( + { + success: true, + context: { + sessionEntry: { + messages: [ + { role: 'user', content: 'turn 1' }, + { role: 'assistant', content: 'reply 1' }, + { role: 'user', content: 'turn 2' }, + { role: 'assistant', content: 'reply 2' }, + { role: 'user', content: 'turn 3' }, + { role: 'assistant', content: 'reply 3' }, + { role: 'user', content: 'turn 4' }, + { role: 'assistant', content: 'reply 4' }, + { role: 'user', content: 'turn 5' }, + { role: 'assistant', content: 'reply 5' }, + { role: 'user', content: 'turn 6' }, + { role: 'assistant', content: 'reply 6' }, + { role: 'user', content: 'turn 7' }, + { role: 'assistant', content: 'reply 7' }, + ], + }, + }, + }, + { + agentId: 'main', + sessionKey: 'agent:main:discord:channel:123', + messageProvider: 'discord', + channelId: 'channel:123', + senderId: 'user:456', + }, + ); + } finally { + hindsight.waitForReady = originalWaitForReady; + hindsight.getClientForContext = originalGetClientForContext; + } + + expect(retained).toHaveLength(1); + expect(retained[0].documentId).toBe('openclaw:agent:main:discord:channel:123:turn:000007'); + expect(retained[0].metadata?.turn_index).toBe('7'); + }); +}); + describe('waitForReady (CLI mode)', () => { it('returns without error when initPromise is null (service.start not called)', async () => { // The module sets up global.__hindsightClient on import. diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 1a33a4609..8de07695f 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -125,7 +125,6 @@ function toStringMetadata( } return out; } -const turnCountBySession = new Map(); const MAX_TRACKED_SESSIONS = 10_000; const DEFAULT_RECALL_TIMEOUT_MS = 10_000; @@ -1739,17 +1738,24 @@ ${memoriesFormatted} let messagesToRetain = allMessages; let retainFullWindow = false; - if (retainEveryN > 1) { - const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`; - const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1; - setCappedMapValue(turnCountBySession, sessionTrackingKey, turnCount); + const conversationTurnCount = countUserTurns(allMessages); + if (conversationTurnCount <= 0) { + debug('[Hindsight Hook] No user turns found, skipping retain'); + return; + } - if (turnCount % retainEveryN !== 0) { - const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN; - debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`); - return; - } + const retainTurnIndex = getRetentionTurnIndex(conversationTurnCount, retainEveryN); + if (retainTurnIndex === null) { + const nextRetainAt = Math.ceil(conversationTurnCount / retainEveryN) * retainEveryN; + debug(`[Hindsight Hook] Turn ${conversationTurnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`); + return; + } + if (retainEveryN > 1) { + // Deliberately require the exact boundary turn from persisted history. + // If a hook run is skipped at turn N, we do not try to backfill that + // missed window on turn N+1; the next retained window is the next exact + // multiple. That keeps window numbering deterministic across resumes. // Sliding window in turns: N turns + configured overlap turns. // We slice by actual turn boundaries (user-role messages), so this // remains stable even when system/tool messages are present. @@ -1757,7 +1763,7 @@ ${memoriesFormatted} const windowTurns = retainEveryN + overlapTurns; messagesToRetain = sliceLastTurnsByUserBoundary(allMessages, windowTurns); retainFullWindow = true; - debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`); + debug(`[Hindsight Hook] Turn ${conversationTurnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`); } const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow); @@ -1799,6 +1805,7 @@ ${memoriesFormatted} { retentionScope: retainFullWindow ? 'window' : 'turn', windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined, + turnIndex: retainTurnIndex, }, ); @@ -1856,6 +1863,10 @@ function getSessionDocumentBase(effectiveCtx: PluginHookAgentContext | undefined } function nextDocumentSequence(effectiveCtx: PluginHookAgentContext | undefined): number { + // Legacy best-effort fallback for direct callers that do not pass turnIndex. + // The agent_end hook always supplies a history-derived turn index, which is + // restart-safe. This counter is process-local only, so callers that need + // stable document ids across resumes should pass options.turnIndex. const sequenceKey = effectiveCtx?.sessionKey || 'session'; const next = (documentSequenceBySession.get(sequenceKey) || 0) + 1; setCappedMapValue(documentSequenceBySession, sequenceKey, next); @@ -2096,6 +2107,30 @@ function buildToolResultBlock(msg: any): any | null { return block; } +export function countUserTurns(messages: Array<{ role?: string }>): number { + if (!Array.isArray(messages) || messages.length === 0) { + return 0; + } + + return messages.reduce((count: number, message) => count + (message?.role === 'user' ? 1 : 0), 0); +} + +export function getRetentionTurnIndex(conversationTurnCount: number, retainEveryN: number): number | null { + if (conversationTurnCount <= 0 || retainEveryN <= 0) { + return null; + } + + if (retainEveryN === 1) { + return conversationTurnCount; + } + + if (conversationTurnCount % retainEveryN !== 0) { + return null; + } + + return Math.floor(conversationTurnCount / retainEveryN); +} + export function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[] { if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) { return [];