From 7877039423088d0d883f5b6ea9bff0386b4374bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 14 Apr 2026 14:54:28 +0200 Subject: [PATCH 1/4] fix(openclaw): make identity skip filters config-aware for per-agent banking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When dynamicBankGranularity includes 'agent', each agent should get its own bank — including 'main' and CLI sessions. The existing filters in getIdentitySkipReason() unconditionally rejected agent:*:main sessions, provider 'main', and anonymous senderIds, which prevented per-agent banks from ever being created for the main agent or any CLI-accessed agent. Thread pluginConfig through resolveAndCacheIdentity to getIdentitySkipReason, and when per-agent banking is enabled: - allow agent:*:main sessions through - allow provider 'main' (still skip cron/heartbeat/subagent) - synthesize agent-user: for anonymous CLI sessions Default behavior is unchanged when dynamicBankGranularity does not include 'agent'. Fixes #1046 --- .../openclaw/src/index.test.ts | 46 +++++++++++++++++++ hindsight-integrations/openclaw/src/index.ts | 28 ++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 1f99cd669..77f287d41 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -836,6 +836,52 @@ describe('session identity helpers', () => { }); }); + it('allows agent:*:main sessions through when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:project-alpha:main' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toBeUndefined(); + expect(result.resolvedCtx?.agentId).toBe('project-alpha'); + expect(result.resolvedCtx?.senderId).toBe('agent-user:project-alpha'); + }); + + it('allows provider main when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:main' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toBeUndefined(); + }); + + it('still skips cron/heartbeat/subagent providers when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:cron:nightly:cleanup' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toEqual({ + kind: 'final', + detail: 'operational session agent:main:cron:nightly:cleanup', + }); + }); + + it('synthesizes sender identity for anonymous CLI sessions when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { agentId: 'project-beta', messageProvider: 'cli', senderId: 'anonymous' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toBeUndefined(); + expect(result.resolvedCtx?.senderId).toBe('agent-user:project-beta'); + }); + + it('preserves default skip behavior when agent banking is not enabled', () => { + const result = getIdentitySkipReason({ sessionKey: 'agent:main:main' }, {}); + expect(result.reason).toEqual({ + kind: 'final', + detail: 'internal main session agent:main:main', + }); + }); + it('detects ephemeral operational text with or without transcript wrappers', () => { expect(isEphemeralOperationalText('A new session was started via /reset.')).toBe(true); expect( diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index d0d5c7860..8d2eaef29 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -741,6 +741,7 @@ interface ResolveAndCacheIdentityOptions { ctx?: PluginHookAgentContext; senderIdHint?: string; dispatchChannel?: string; + pluginConfig?: PluginConfig; } function resolveAndCacheIdentity( @@ -787,7 +788,7 @@ function resolveAndCacheIdentity( cacheSessionIdentity(sessionKey, resolvedCtx); - const { reason: skipReason } = getIdentitySkipReason(resolvedCtx); + const { reason: skipReason } = getIdentitySkipReason(resolvedCtx, options.pluginConfig); if (sessionKey) { if (skipReason) { setCappedMapValue(skipHindsightTurnBySession, sessionKey, skipReason); @@ -801,15 +802,21 @@ function resolveAndCacheIdentity( export function getIdentitySkipReason( ctx: PluginHookAgentContext | undefined, + pluginConfig?: PluginConfig, ): { resolvedCtx: PluginHookAgentContext | undefined; reason?: IdentitySkipReason } { const resolvedCtx = resolveSessionIdentity(ctx); const sessionKey = resolvedCtx?.sessionKey; + // When per-agent banking is enabled, CLI/main sessions are legitimate (each agent + // gets its own bank, including 'main'), so the "internal main" / "operational + // provider main" / "anonymous sender" filters that exist for the default static + // bank mode would otherwise prevent any per-agent bank from being created. + const agentBanking = pluginConfig?.dynamicBankGranularity?.includes('agent') ?? false; if (typeof sessionKey === 'string') { if (/^agent:[^:]+:(cron|heartbeat|subagent):/.test(sessionKey)) { return { resolvedCtx, reason: finalSkipReason(`operational session ${sessionKey}`) }; } - if (/^agent:[^:]+:main$/.test(sessionKey)) { + if (!agentBanking && /^agent:[^:]+:main$/.test(sessionKey)) { return { resolvedCtx, reason: finalSkipReason(`internal main session ${sessionKey}`) }; } if (/^temp:/.test(sessionKey)) { @@ -817,14 +824,21 @@ export function getIdentitySkipReason( } } - if (resolvedCtx?.messageProvider && ['cron', 'heartbeat', 'subagent', 'main'].includes(resolvedCtx.messageProvider)) { + const operationalProviders = agentBanking + ? ['cron', 'heartbeat', 'subagent'] + : ['cron', 'heartbeat', 'subagent', 'main']; + if (resolvedCtx?.messageProvider && operationalProviders.includes(resolvedCtx.messageProvider)) { return { resolvedCtx, reason: finalSkipReason(`operational provider ${resolvedCtx.messageProvider}`) }; } if (!resolvedCtx?.messageProvider || resolvedCtx.messageProvider === 'unknown') { return { resolvedCtx, reason: retryableSkipReason('missing stable message provider') }; } if (!resolvedCtx?.senderId || resolvedCtx.senderId === 'anonymous') { - return { resolvedCtx, reason: retryableSkipReason('missing stable sender identity') }; + if (agentBanking && resolvedCtx?.agentId) { + resolvedCtx.senderId = `agent-user:${resolvedCtx.agentId}`; + } else { + return { resolvedCtx, reason: retryableSkipReason('missing stable sender identity') }; + } } if ( resolvedCtx.messageProvider === 'telegram' && @@ -1520,6 +1534,7 @@ export default function (api: MoltbotPluginAPI) { ctx?.senderId, }, dispatchChannel, + pluginConfig, }); if (skipReason) { @@ -1543,7 +1558,7 @@ export default function (api: MoltbotPluginAPI) { api.on('before_agent_start', async (event: any, ctx?: PluginHookAgentContext) => { try { const sessionKey = ctx?.sessionKey ?? (typeof event?.sessionKey === 'string' ? event.sessionKey : undefined); - const { resolvedCtx, skipReason } = resolveAndCacheIdentity({ sessionKey, ctx }); + const { resolvedCtx, skipReason } = resolveAndCacheIdentity({ sessionKey, ctx, pluginConfig }); if (sessionKey && skipReason) { debug(`[Hindsight] before_agent_start skipping session ${sessionKey}: ${formatIdentitySkipReason(skipReason)}`); @@ -1604,6 +1619,7 @@ export default function (api: MoltbotPluginAPI) { sessionKey: sessionKeyForCache, ctx, senderIdHint: senderIdFromPrompt, + pluginConfig, }); if (identitySkipReason) { debug(`[Hindsight] Skipping recall for session ${sessionKeyForCache}: ${formatIdentitySkipReason(identitySkipReason)}`); @@ -1769,7 +1785,7 @@ ${memoriesFormatted} } const { effectiveCtx: effectiveCtxForRetain, resolvedCtx: resolvedCtxForRetain, skipReason: identitySkipReason } = - resolveAndCacheIdentity({ sessionKey: sessionKeyForLookup, ctx: effectiveCtx }); + resolveAndCacheIdentity({ sessionKey: sessionKeyForLookup, ctx: effectiveCtx, pluginConfig }); if (identitySkipReason) { debug(`[Hindsight Hook] Skipping retain for session ${sessionKeyForLookup}: ${formatIdentitySkipReason(identitySkipReason)}`); From e335f9fb3e612586f609d71e24aab4a78bd2188d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 14 Apr 2026 15:27:02 +0200 Subject: [PATCH 2/4] fix(openclaw): also bypass CLI session filters for static bankId mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the carve-out so the same skip-bypass behavior fires when the user has explicitly opted into a single named bank via dynamicBankId=false + bankId. In that mode every session — including agent:*:main, provider 'main', and anonymous senders — should retain into the configured bank. The carve-out still requires a non-empty bankId; dynamicBankId=false alone doesn't trigger it (the bank would be unresolvable). --- .../openclaw/src/index.test.ts | 20 ++++++++++++++++ hindsight-integrations/openclaw/src/index.ts | 23 +++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 77f287d41..a2125a6bc 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -874,6 +874,26 @@ describe('session identity helpers', () => { expect(result.resolvedCtx?.senderId).toBe('agent-user:project-beta'); }); + it('allows agent:*:main sessions through when a static bankId is configured', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:main' }, + { dynamicBankId: false, bankId: 'shared-bank' }, + ); + expect(result.reason).toBeUndefined(); + expect(result.resolvedCtx?.senderId).toBe('agent-user:main'); + }); + + it('does not broaden the carve-out when dynamicBankId is false but bankId is missing', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:main' }, + { dynamicBankId: false }, + ); + expect(result.reason).toEqual({ + kind: 'final', + detail: 'internal main session agent:main:main', + }); + }); + it('preserves default skip behavior when agent banking is not enabled', () => { const result = getIdentitySkipReason({ sessionKey: 'agent:main:main' }, {}); expect(result.reason).toEqual({ diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 8d2eaef29..dc9f6be77 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -806,17 +806,26 @@ export function getIdentitySkipReason( ): { resolvedCtx: PluginHookAgentContext | undefined; reason?: IdentitySkipReason } { const resolvedCtx = resolveSessionIdentity(ctx); const sessionKey = resolvedCtx?.sessionKey; - // When per-agent banking is enabled, CLI/main sessions are legitimate (each agent - // gets its own bank, including 'main'), so the "internal main" / "operational - // provider main" / "anonymous sender" filters that exist for the default static - // bank mode would otherwise prevent any per-agent bank from being created. + // The "internal main" / "operational provider main" / "anonymous sender" filters + // exist to keep the default multi-tenant bank from being polluted by CLI/main + // sessions that lack a stable identity. They should NOT fire when the user has + // explicitly opted into a routing scheme that expects those sessions: + // - dynamicBankGranularity includes 'agent' → each agent (including 'main') + // gets its own bank + // - dynamicBankId === false with a configured bankId → user pinned a single + // named bank and wants every session retained into it const agentBanking = pluginConfig?.dynamicBankGranularity?.includes('agent') ?? false; + const staticBanking = + pluginConfig?.dynamicBankId === false && + typeof pluginConfig?.bankId === 'string' && + pluginConfig.bankId.length > 0; + const allowCliSessions = agentBanking || staticBanking; if (typeof sessionKey === 'string') { if (/^agent:[^:]+:(cron|heartbeat|subagent):/.test(sessionKey)) { return { resolvedCtx, reason: finalSkipReason(`operational session ${sessionKey}`) }; } - if (!agentBanking && /^agent:[^:]+:main$/.test(sessionKey)) { + if (!allowCliSessions && /^agent:[^:]+:main$/.test(sessionKey)) { return { resolvedCtx, reason: finalSkipReason(`internal main session ${sessionKey}`) }; } if (/^temp:/.test(sessionKey)) { @@ -824,7 +833,7 @@ export function getIdentitySkipReason( } } - const operationalProviders = agentBanking + const operationalProviders = allowCliSessions ? ['cron', 'heartbeat', 'subagent'] : ['cron', 'heartbeat', 'subagent', 'main']; if (resolvedCtx?.messageProvider && operationalProviders.includes(resolvedCtx.messageProvider)) { @@ -834,7 +843,7 @@ export function getIdentitySkipReason( return { resolvedCtx, reason: retryableSkipReason('missing stable message provider') }; } if (!resolvedCtx?.senderId || resolvedCtx.senderId === 'anonymous') { - if (agentBanking && resolvedCtx?.agentId) { + if (allowCliSessions && resolvedCtx?.agentId) { resolvedCtx.senderId = `agent-user:${resolvedCtx.agentId}`; } else { return { resolvedCtx, reason: retryableSkipReason('missing stable sender identity') }; From 6e83f8f1ea1bc0dec289bf4509e665ee50c63d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 14 Apr 2026 17:43:35 +0200 Subject: [PATCH 3/4] fix(openclaw): strip inline retain tags in structured block path extractStructuredBlocks was calling stripMemoryTags + stripMetadataEnvelopes but not stripInlineRetainTags, so ... directives survived into the retained JSON transcript on the default retainFormat=json + retainToolCalls=true path. --- hindsight-integrations/openclaw/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index dc9f6be77..cb49b5fb7 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -2163,7 +2163,7 @@ function buildAnthropicStructuredMessages( function extractStructuredBlocks(content: any, role: string): any[] { if (typeof content === 'string') { - const cleaned = stripMetadataEnvelopes(stripMemoryTags(content)).trim(); + const cleaned = stripMetadataEnvelopes(stripInlineRetainTags(stripMemoryTags(content))).trim(); return cleaned ? [{ type: 'text', text: cleaned }] : []; } if (!Array.isArray(content)) return []; @@ -2174,7 +2174,7 @@ function extractStructuredBlocks(content: any, role: string): any[] { const blockType = block.type; if (blockType === 'text') { - const cleaned = stripMetadataEnvelopes(stripMemoryTags(block.text ?? '')).trim(); + const cleaned = stripMetadataEnvelopes(stripInlineRetainTags(stripMemoryTags(block.text ?? ''))).trim(); if (cleaned) blocks.push({ type: 'text', text: cleaned }); } else if (blockType === 'toolCall' && role === 'assistant') { const name = typeof block.name === 'string' ? block.name : 'unknown'; From d99169158ba87cfbe25db9ee62ae96ab67d6ba7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 14 Apr 2026 17:50:10 +0200 Subject: [PATCH 4/4] test(openclaw): update hook integration tests to default json retain format The two transcript-format assertions still expected the legacy text markers (`[role: user] ... [user:end]`), but the default retainFormat is now 'json' with Anthropic-shaped typed blocks. Parse the JSON and assert against the structured shape instead. --- .../openclaw/tests/hooks.integration.test.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts index a1a0e139c..6b6f4e081 100644 --- a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts +++ b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts @@ -476,13 +476,13 @@ describe('agent_end hook', () => { expect(retainSpy).toHaveBeenCalledOnce(); // HindsightClient.retain signature: (bankId, content, options?) + // Default retainFormat is 'json' with Anthropic-shaped typed blocks. const [, content] = retainSpy.mock.calls[0]; - expect(content).toContain('[role: user]'); - expect(content).toContain('I love TypeScript.'); - expect(content).toContain('[user:end]'); - expect(content).toContain('[role: assistant]'); - expect(content).toContain('TypeScript is great!'); - expect(content).toContain('[assistant:end]'); + const parsed = JSON.parse(content); + expect(parsed).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'I love TypeScript.' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'TypeScript is great!' }] }, + ]); }); it('includes session key in documentId', async () => { @@ -675,9 +675,13 @@ describe('agent_end hook', () => { expect(retainSpy).toHaveBeenCalledOnce(); const [, content, options] = retainSpy.mock.calls[0]; - // Only the last turn (from last user message onwards) is retained - expect(content).toContain('[role: user]\nI work as a data scientist.\n[user:end]'); - expect(content).toContain("[role: assistant]\nThat's a fascinating career!\n[assistant:end]"); + // Only the last turn (from last user message onwards) is retained. + // Default retainFormat is 'json' with Anthropic-shaped typed blocks. + const parsed = JSON.parse(content); + expect(parsed).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'I work as a data scientist.' }] }, + { role: 'assistant', content: [{ type: 'text', text: "That's a fascinating career!" }] }, + ]); expect(content).not.toContain('My name is Carol.'); expect(options?.metadata?.message_count).toBe('2'); });