Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions hindsight-integrations/openclaw/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,72 @@ 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('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({
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(
Expand Down
41 changes: 33 additions & 8 deletions hindsight-integrations/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,7 @@ interface ResolveAndCacheIdentityOptions {
ctx?: PluginHookAgentContext;
senderIdHint?: string;
dispatchChannel?: string;
pluginConfig?: PluginConfig;
}

function resolveAndCacheIdentity(
Expand Down Expand Up @@ -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);
Expand All @@ -801,30 +802,52 @@ function resolveAndCacheIdentity(

export function getIdentitySkipReason(
ctx: PluginHookAgentContext | undefined,
pluginConfig?: PluginConfig,
): { resolvedCtx: PluginHookAgentContext | undefined; reason?: IdentitySkipReason } {
const resolvedCtx = resolveSessionIdentity(ctx);
const sessionKey = resolvedCtx?.sessionKey;
// 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 (/^agent:[^:]+:main$/.test(sessionKey)) {
if (!allowCliSessions && /^agent:[^:]+:main$/.test(sessionKey)) {
return { resolvedCtx, reason: finalSkipReason(`internal main session ${sessionKey}`) };
}
if (/^temp:/.test(sessionKey)) {
return { resolvedCtx, reason: finalSkipReason(`ephemeral temp session ${sessionKey}`) };
}
}

if (resolvedCtx?.messageProvider && ['cron', 'heartbeat', 'subagent', 'main'].includes(resolvedCtx.messageProvider)) {
const operationalProviders = allowCliSessions
? ['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 (allowCliSessions && resolvedCtx?.agentId) {
resolvedCtx.senderId = `agent-user:${resolvedCtx.agentId}`;
} else {
return { resolvedCtx, reason: retryableSkipReason('missing stable sender identity') };
}
}
if (
resolvedCtx.messageProvider === 'telegram' &&
Expand Down Expand Up @@ -1520,6 +1543,7 @@ export default function (api: MoltbotPluginAPI) {
ctx?.senderId,
},
dispatchChannel,
pluginConfig,
});

if (skipReason) {
Expand All @@ -1543,7 +1567,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)}`);
Expand Down Expand Up @@ -1604,6 +1628,7 @@ export default function (api: MoltbotPluginAPI) {
sessionKey: sessionKeyForCache,
ctx,
senderIdHint: senderIdFromPrompt,
pluginConfig,
});
if (identitySkipReason) {
debug(`[Hindsight] Skipping recall for session ${sessionKeyForCache}: ${formatIdentitySkipReason(identitySkipReason)}`);
Expand Down Expand Up @@ -1769,7 +1794,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)}`);
Expand Down Expand Up @@ -2138,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 [];
Expand All @@ -2149,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';
Expand Down
22 changes: 13 additions & 9 deletions hindsight-integrations/openclaw/tests/hooks.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
Expand Down