feat(session): add optional end-of-session summarization

This commit is contained in:
William Valentin
2026-02-16 13:18:42 -08:00
parent 01ee6ba53f
commit 3f627cc1ad
8 changed files with 307 additions and 0 deletions
+46
View File
@@ -244,6 +244,52 @@ describe('configSchema — backup', () => {
});
});
describe('configSchema — sessions', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('defaults end_summary settings', () => {
const result = configSchema.parse(minimalConfig);
expect(result.sessions.ttl).toBe('30d');
expect(result.sessions.end_summary.enabled).toBe(false);
expect(result.sessions.end_summary.tier).toBe('fast');
expect(result.sessions.end_summary.max_messages).toBe(50);
expect(result.sessions.end_summary.max_input_chars).toBe(20000);
expect(result.sessions.end_summary.max_tokens).toBe(512);
expect(result.sessions.end_summary.write_to_memory).toBe(true);
expect(result.sessions.end_summary.memory_namespace).toBe('session/summaries');
});
it('accepts custom end_summary settings', () => {
const result = configSchema.parse({
...minimalConfig,
sessions: {
ttl: '7d',
end_summary: {
enabled: true,
tier: 'complex',
max_messages: 100,
max_input_chars: 50000,
max_tokens: 1024,
write_to_memory: false,
memory_namespace: 'notes/session',
},
},
});
expect(result.sessions.ttl).toBe('7d');
expect(result.sessions.end_summary.enabled).toBe(true);
expect(result.sessions.end_summary.tier).toBe('complex');
expect(result.sessions.end_summary.max_messages).toBe(100);
expect(result.sessions.end_summary.max_input_chars).toBe(50000);
expect(result.sessions.end_summary.max_tokens).toBe(1024);
expect(result.sessions.end_summary.write_to_memory).toBe(false);
expect(result.sessions.end_summary.memory_namespace).toBe('notes/session');
});
});
describe('configSchema — agent_configs', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+9
View File
@@ -670,6 +670,15 @@ const promptSchema = z.object({
const sessionsSchema = z.object({
ttl: z.string().default('30d'),
end_summary: z.object({
enabled: z.boolean().default(false),
tier: modelTierEnum.default('fast'),
max_messages: z.number().min(2).max(500).default(50),
max_input_chars: z.number().min(500).max(200000).default(20000),
max_tokens: z.number().min(64).max(4096).default(512),
write_to_memory: z.boolean().default(true),
memory_namespace: z.string().default('session/summaries'),
}).default({}),
}).default({});
const backupSchema = z.object({
+51
View File
@@ -22,6 +22,7 @@ const mockSessionManager = {
};
const mockModelClient = {
getClient: vi.fn(() => mockModelClient),
chat: vi.fn(async () => ({
content: 'test',
stopReason: 'end_turn',
@@ -103,6 +104,56 @@ describe('SessionBridge', () => {
expect(bridge.getAgent('conn-1')).toBeUndefined();
});
it('runs session-end summary on final disconnect when enabled', async () => {
mockSession.getHistory.mockReturnValueOnce([
{ role: 'user', content: 'Please remember my preference for concise updates.' },
{ role: 'assistant', content: 'Noted.' },
] as any);
const memoryStore = {
write: vi.fn(),
};
const bridge = new SessionBridge({
sessionManager: mockSessionManager as unknown as SessionBridgeConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'test prompt',
toolRegistry: mockToolRegistry as unknown as SessionBridgeConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as SessionBridgeConfig['toolExecutor'],
memoryStore: memoryStore as unknown as SessionBridgeConfig['memoryStore'],
config: {
agents: {
primary_tier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'fast',
classification: 'fast',
tool_summarisation: 'fast',
complex_reasoning: 'complex',
},
max_delegation_depth: 3,
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } },
sessions: {
ttl: '30d',
end_summary: {
enabled: true,
tier: 'fast',
max_messages: 50,
max_input_chars: 20000,
max_tokens: 256,
write_to_memory: true,
memory_namespace: 'session/summaries',
},
},
} as unknown as SessionBridgeConfig['config'],
});
bridge.connect('conn-end-summary');
bridge.disconnect('conn-end-summary');
await new Promise(resolve => setTimeout(resolve, 0));
expect(memoryStore.write).toHaveBeenCalled();
});
it('tracks busy state', () => {
const bridge = createBridge();
bridge.connect('conn-1');
+24
View File
@@ -8,6 +8,7 @@ import { AgentOrchestrator, type DelegationConfig } from '../backends/native/orc
import type { ToolUseEvent } from '../backends/native/agent.js';
import type { MemoryStore } from '../memory/store.js';
import type { Config } from '../config/index.js';
import { summarizeSessionOnEnd, type SessionEndSummaryConfig } from '../session/endSummary.js';
export interface SessionBridgeConfig {
sessionManager: SessionManager;
@@ -59,6 +60,29 @@ export class SessionBridge {
const otherClients = Array.from(this.clients.values())
.filter(c => c.sessionId === client.sessionId && c.connectionId !== connectionId);
if (otherClients.length === 0) {
const agent = this.agents.get(client.sessionId);
const summaryConfig = this.config.config?.sessions?.end_summary;
if (agent && summaryConfig?.enabled) {
const history = agent.getHistory();
const mappedConfig: SessionEndSummaryConfig = {
enabled: summaryConfig.enabled,
tier: summaryConfig.tier,
maxMessages: summaryConfig.max_messages,
maxInputChars: summaryConfig.max_input_chars,
maxTokens: summaryConfig.max_tokens,
writeToMemory: summaryConfig.write_to_memory,
memoryNamespace: summaryConfig.memory_namespace,
};
void summarizeSessionOnEnd({
agent,
sessionId: client.sessionId,
history,
config: mappedConfig,
memoryStore: this.config.memoryStore,
}).catch((error) => {
console.warn('Session end summary failed:', error);
});
}
this.agents.delete(client.sessionId);
}
this.clients.delete(connectionId);
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import type { AgentOrchestrator } from '../backends/native/orchestrator.js';
import { summarizeSessionOnEnd, sessionEndSummaryInternals } from './endSummary.js';
import type { MemoryStore } from '../memory/store.js';
describe('session end summary', () => {
it('sanitizes session ids for memory namespaces', () => {
expect(sessionEndSummaryInternals.sanitizeSessionId('ws:abc-123')).toBe('ws/abc-123');
expect(sessionEndSummaryInternals.sanitizeSessionId('telegram:user@x')).toBe('telegram/user_x');
});
it('summarizes and writes to memory when enabled', async () => {
const agent = {
delegate: vi.fn().mockResolvedValue({
content: '- User prefers concise responses.\n- Next step: run deploy tomorrow.',
usage: { inputTokens: 10, outputTokens: 8 },
tier: 'fast',
}),
} as unknown as AgentOrchestrator;
const memoryStore = {
write: vi.fn(),
} as unknown as MemoryStore;
const result = await summarizeSessionOnEnd({
agent,
sessionId: 'ws:conn-1',
history: [
{ role: 'user', content: 'Please keep answers concise.' },
{ role: 'assistant', content: 'Noted.' },
],
config: {
enabled: true,
tier: 'fast',
maxMessages: 50,
maxInputChars: 20000,
maxTokens: 512,
writeToMemory: true,
memoryNamespace: 'session/summaries',
},
memoryStore,
});
expect(agent.delegate).toHaveBeenCalledTimes(1);
expect(result?.summary).toContain('concise');
expect(result?.namespace).toBe('session/summaries/ws/conn-1');
expect(memoryStore.write).toHaveBeenCalledWith(
'session/summaries/ws/conn-1',
expect.stringContaining('User prefers concise responses'),
'append',
);
});
it('skips when disabled', async () => {
const agent = {
delegate: vi.fn(),
} as unknown as AgentOrchestrator;
const result = await summarizeSessionOnEnd({
agent,
sessionId: 'ws:conn-1',
history: [{ role: 'user', content: 'hello' }],
config: {
enabled: false,
tier: 'fast',
maxMessages: 50,
maxInputChars: 20000,
maxTokens: 512,
writeToMemory: true,
memoryNamespace: 'session/summaries',
},
});
expect(result).toBeNull();
expect(agent.delegate).not.toHaveBeenCalled();
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { AgentOrchestrator } from '../backends/native/orchestrator.js';
import type { Message } from '../models/types.js';
import { getMessageText } from '../models/media.js';
import type { MemoryStore } from '../memory/store.js';
import type { ModelTier } from '../models/router.js';
export interface SessionEndSummaryConfig {
enabled: boolean;
tier: ModelTier;
maxMessages: number;
maxInputChars: number;
maxTokens: number;
writeToMemory: boolean;
memoryNamespace: string;
}
export interface SessionEndSummaryResult {
summary: string;
namespace?: string;
}
const SESSION_END_SUMMARY_PROMPT = [
'Summarize this completed conversation for future continuity.',
'Focus on durable facts, decisions, preferences, unresolved tasks, and next steps.',
'Be concise and structured with short bullets.',
'Do not include private secrets.',
].join(' ');
function sanitizeSessionId(sessionId: string): string {
return sessionId
.replace(/:/g, '/')
.replace(/[^a-zA-Z0-9/_-]+/g, '_')
.replace(/_+/g, '_');
}
function formatHistory(messages: Message[], maxInputChars: number): string {
const text = messages
.map((msg) => `${msg.role}: ${getMessageText(msg)}`)
.join('\n\n');
if (text.length <= maxInputChars) {
return text;
}
return text.slice(text.length - maxInputChars);
}
export async function summarizeSessionOnEnd(opts: {
agent: AgentOrchestrator;
sessionId: string;
history: Message[];
config: SessionEndSummaryConfig;
memoryStore?: MemoryStore;
}): Promise<SessionEndSummaryResult | null> {
if (!opts.config.enabled || opts.history.length === 0) {
return null;
}
const selected = opts.history.slice(-opts.config.maxMessages);
const conversation = formatHistory(selected, opts.config.maxInputChars);
if (!conversation.trim()) {
return null;
}
const result = await opts.agent.delegate({
tier: opts.config.tier,
systemPrompt: SESSION_END_SUMMARY_PROMPT,
message: conversation,
maxTokens: opts.config.maxTokens,
});
const summary = result.content.trim();
if (!summary) {
return null;
}
let namespace: string | undefined;
if (opts.memoryStore && opts.config.writeToMemory) {
namespace = `${opts.config.memoryNamespace}/${sanitizeSessionId(opts.sessionId)}`;
const block = `## ${new Date().toISOString()}\n\n${summary}\n\n`;
opts.memoryStore.write(namespace, block, 'append');
}
return { summary, namespace };
}
export const sessionEndSummaryInternals = {
sanitizeSessionId,
formatHistory,
};
+1
View File
@@ -4,3 +4,4 @@ export { SessionIndexer, tokenize } from './indexer.js';
export type { HistoryMetadata, HistoryIndexerConfig } from './indexer.js';
export { SessionSearch } from './search.js';
export type { HistorySearchResult, HistorySearchConfig } from './search.js';
export { summarizeSessionOnEnd, sessionEndSummaryInternals, type SessionEndSummaryConfig, type SessionEndSummaryResult } from './endSummary.js';