feat(memory): inject session context and write working memory after compaction
This commit is contained in:
@@ -5,6 +5,7 @@ import type { ChatResponse, ModelClient } from '../../models/types.js';
|
||||
import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
|
||||
import { HookEngine } from '../../hooks/engine.js';
|
||||
import { MemoryStore } from '../../memory/store.js';
|
||||
import { writeWorkingMemory } from '../../memory/workingMemory.js';
|
||||
import type { Session } from '../../session/index.js';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -453,6 +454,49 @@ describe('AgentOrchestrator', () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects session context from user profile and working memory', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-session-context-'));
|
||||
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
|
||||
memoryStore.write('user/profile', 'Name: Will', 'replace');
|
||||
writeWorkingMemory(memoryStore, 'user/working', 'Recent task: build feature X', 14, 1000);
|
||||
|
||||
const mockDefaultChatClient = requireClient('default');
|
||||
const mockDefaultChatFn = vi.fn().mockResolvedValue({
|
||||
content: 'Agent response',
|
||||
stopReason: 'end_turn',
|
||||
usage: { inputTokens: 50, outputTokens: 25 },
|
||||
} as ChatResponse);
|
||||
Object.assign(mockDefaultChatClient, { chat: mockDefaultChatFn });
|
||||
|
||||
const orchestrator = new AgentOrchestrator({
|
||||
modelRouter: mockRouter,
|
||||
systemPrompt: 'You are a helpful agent.',
|
||||
primaryTier: 'default',
|
||||
delegation: {
|
||||
compaction: 'fast',
|
||||
memory_extraction: 'default',
|
||||
classification: 'complex',
|
||||
tool_summarisation: 'default',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
maxDelegationDepth: 10,
|
||||
memoryStore,
|
||||
userNamespace: 'user',
|
||||
workingMemoryTtlDays: 14,
|
||||
workingMemoryMaxTokens: 1000,
|
||||
});
|
||||
|
||||
await orchestrator.process('Hello');
|
||||
|
||||
const callArgs = mockDefaultChatFn.mock.calls[0][0];
|
||||
expect(callArgs.system).toContain('--- Who you\'re talking to ---');
|
||||
expect(callArgs.system).toContain('Name: Will');
|
||||
expect(callArgs.system).toContain('--- Recent context ---');
|
||||
expect(callArgs.system).toContain('Recent task: build feature X');
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('appends daily memory log entries when enabled', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-daily-log-'));
|
||||
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
|
||||
@@ -640,6 +684,54 @@ describe('AgentOrchestrator', () => {
|
||||
initAuditLogger(previousAuditLogger as unknown as AuditLogger);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes working memory after compaction when user namespace is set', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-working-memory-'));
|
||||
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
|
||||
|
||||
const mockFastChatClient = requireClient('fast');
|
||||
const mockFastChatFn = vi.fn().mockResolvedValue({
|
||||
content: 'Working summary content',
|
||||
stopReason: 'end_turn',
|
||||
usage: { inputTokens: 50, outputTokens: 25 },
|
||||
} as ChatResponse);
|
||||
Object.assign(mockFastChatClient, { chat: mockFastChatFn });
|
||||
|
||||
const orchestrator = new AgentOrchestrator({
|
||||
modelRouter: mockRouter,
|
||||
systemPrompt: 'You are helpful.',
|
||||
primaryTier: 'default',
|
||||
delegation: {
|
||||
compaction: 'fast',
|
||||
memory_extraction: 'default',
|
||||
classification: 'complex',
|
||||
tool_summarisation: 'default',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
maxDelegationDepth: 10,
|
||||
compaction: {
|
||||
thresholdPct: 80,
|
||||
keepTurns: 1,
|
||||
summaryMaxTokens: 128,
|
||||
importanceThreshold: 1,
|
||||
},
|
||||
memoryStore,
|
||||
memoryAutoExtract: false,
|
||||
userNamespace: 'user',
|
||||
workingMemoryTtlDays: 14,
|
||||
workingMemoryMaxTokens: 1000,
|
||||
});
|
||||
|
||||
await orchestrator.process('First message');
|
||||
await orchestrator.process('Second message');
|
||||
await orchestrator.compact();
|
||||
|
||||
const working = memoryStore.read('user/working');
|
||||
expect(working).toContain('# Working Memory');
|
||||
expect(working).toContain('Working summary content');
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset()', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_C
|
||||
import { estimateCost } from '../../models/costs.js';
|
||||
import { auditLogger } from '../../audit/index.js';
|
||||
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js';
|
||||
import { readWorkingMemory, writeWorkingMemory } from '../../memory/workingMemory.js';
|
||||
import { buildUserMessage } from '../../models/media.js';
|
||||
import { CONTEXT_CHECKPOINT_PROMPT, MEMORY_EXTRACTION_PROMPT } from './prompts.js';
|
||||
|
||||
@@ -157,6 +158,14 @@ export interface OrchestratorConfig {
|
||||
toolPolicyContext?: ToolPolicyContext;
|
||||
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
||||
attachmentCollector?: OutboundAttachmentCollector;
|
||||
/** Shared identity namespace for cross-channel memory (e.g. 'user'). Absent = session-scoped. */
|
||||
userNamespace?: string;
|
||||
/** TTL in days for working memory. Defaults to 14. */
|
||||
workingMemoryTtlDays?: number;
|
||||
/** Token budget for working memory injection. Defaults to 1000. */
|
||||
workingMemoryMaxTokens?: number;
|
||||
/** When true, instruct the model to acknowledge prior context on session start. */
|
||||
proactiveSessionGreeting?: boolean;
|
||||
}
|
||||
|
||||
// ── AgentOrchestrator ─────────────────────────────────────────────────
|
||||
@@ -196,9 +205,14 @@ export class AgentOrchestrator {
|
||||
private _memoryDailyLogIncludeSessionMetadata: boolean;
|
||||
private _memoryDailyLogMaxUserChars: number;
|
||||
private _memoryDailyLogMaxAssistantChars: number;
|
||||
private _userNamespace?: string;
|
||||
private _workingMemoryTtlDays: number;
|
||||
private _workingMemoryMaxTokens: number;
|
||||
private _proactiveSessionGreeting: boolean;
|
||||
private _autoEscalate: boolean;
|
||||
private _autoEscalateTier: ModelTier;
|
||||
private _systemPromptBase: string;
|
||||
private _sessionContext: string | null = null;
|
||||
private _externalOnToolUse?: (event: ToolUseEvent) => void;
|
||||
private _activeRunToolStarts = 0;
|
||||
private _usageByTier: Map<string, TierUsageStats> = new Map();
|
||||
@@ -228,6 +242,10 @@ export class AgentOrchestrator {
|
||||
this._memoryDailyLogIncludeSessionMetadata = config.memoryDailyLogIncludeSessionMetadata ?? true;
|
||||
this._memoryDailyLogMaxUserChars = Math.max(100, config.memoryDailyLogMaxUserChars ?? 2000);
|
||||
this._memoryDailyLogMaxAssistantChars = Math.max(100, config.memoryDailyLogMaxAssistantChars ?? 4000);
|
||||
this._userNamespace = config.userNamespace;
|
||||
this._workingMemoryTtlDays = config.workingMemoryTtlDays ?? 14;
|
||||
this._workingMemoryMaxTokens = config.workingMemoryMaxTokens ?? 1000;
|
||||
this._proactiveSessionGreeting = config.proactiveSessionGreeting ?? false;
|
||||
this._autoEscalate = config.autoEscalate ?? false;
|
||||
this._autoEscalateTier = config.autoEscalateTier ?? 'complex';
|
||||
this._systemPromptBase = config.systemPrompt;
|
||||
@@ -346,6 +364,7 @@ export class AgentOrchestrator {
|
||||
turnAudioInput?: NativeAgentTurnAudioInput,
|
||||
): Promise<string> {
|
||||
this._activeRunToolStarts = 0;
|
||||
this._buildSessionContext();
|
||||
this._injectMemoryContext(userMessage);
|
||||
await this._runProactiveContextMaintenance();
|
||||
await this.compactIfNeeded();
|
||||
@@ -473,6 +492,8 @@ export class AgentOrchestrator {
|
||||
config,
|
||||
memoryStore: this._memoryStore,
|
||||
autoExtract: this._memoryAutoExtract,
|
||||
usePersonalAssistantPrompt: Boolean(this._userNamespace),
|
||||
memoryExtractionNamespace: this._userNamespace ? `${this._userNamespace}/facts` : undefined,
|
||||
});
|
||||
|
||||
// If nothing was actually compacted, skip the replace
|
||||
@@ -500,6 +521,19 @@ export class AgentOrchestrator {
|
||||
});
|
||||
}
|
||||
|
||||
// Write working memory when user namespace is configured
|
||||
if (result.summary && this._userNamespace && this._memoryStore) {
|
||||
const workingNs = `${this._userNamespace}/working`;
|
||||
writeWorkingMemory(
|
||||
this._memoryStore,
|
||||
workingNs,
|
||||
result.summary,
|
||||
this._workingMemoryTtlDays,
|
||||
this._workingMemoryMaxTokens,
|
||||
);
|
||||
console.log(`[Flynn:working-memory] Updated ${workingNs} after compaction`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -510,6 +544,7 @@ export class AgentOrchestrator {
|
||||
this._lastContextAlertLevel = null;
|
||||
this._pendingContextAlert = undefined;
|
||||
this._lastCheckpointAt = 0;
|
||||
this._sessionContext = null;
|
||||
}
|
||||
|
||||
/** Get the primary agent's conversation history. */
|
||||
@@ -649,6 +684,9 @@ export class AgentOrchestrator {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveBase = this._sessionContext
|
||||
? `${this._systemPromptBase}\n\n${this._sessionContext}`
|
||||
: this._systemPromptBase;
|
||||
let memoryContext = '';
|
||||
try {
|
||||
if (this._memoryInjectionStrategy === 'recent') {
|
||||
@@ -673,14 +711,55 @@ export class AgentOrchestrator {
|
||||
memoryContext = this._clipMemoryContext(memoryContext);
|
||||
|
||||
if (!memoryContext) {
|
||||
this._agent.setSystemPrompt(this._systemPromptBase);
|
||||
this._agent.setSystemPrompt(effectiveBase);
|
||||
return;
|
||||
}
|
||||
|
||||
const enrichedPrompt = `${this._systemPromptBase}\n\n# Memory Context\n\nThe following is your persistent memory. Use it to maintain continuity across sessions.\n\n${memoryContext}`;
|
||||
const enrichedPrompt = `${effectiveBase}\n\n# Memory Context\n\nThe following is your persistent memory. Use it to maintain continuity across sessions.\n\n${memoryContext}`;
|
||||
this._agent.setSystemPrompt(enrichedPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build session context from user/profile and user/working memory.
|
||||
* Called once on first process() call. Returns null if no context available.
|
||||
* Does NOT mutate _systemPromptBase — the result is stored in _sessionContext
|
||||
* and composed into the system prompt by _injectMemoryContext().
|
||||
*/
|
||||
private _buildSessionContext(): void {
|
||||
if (this._sessionContext !== null || !this._memoryStore || !this._userNamespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
// User profile block
|
||||
const profile = this._memoryStore.read(`${this._userNamespace}/profile`);
|
||||
if (profile.length > 0) {
|
||||
sections.push(`--- Who you're talking to ---\n${profile}`);
|
||||
}
|
||||
|
||||
// Working memory block
|
||||
const working = readWorkingMemory(this._memoryStore, `${this._userNamespace}/working`);
|
||||
if (working) {
|
||||
sections.push(`--- Recent context ---\n${working.content}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
// Set to empty string (not null) to indicate we've run but found nothing.
|
||||
// Null means "not yet computed".
|
||||
this._sessionContext = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let ctx = sections.join('\n\n');
|
||||
|
||||
if (this._proactiveSessionGreeting) {
|
||||
ctx += '\n\n[If relevant, briefly acknowledge what the user was last working on before responding to their first message.]';
|
||||
}
|
||||
|
||||
this._sessionContext = ctx;
|
||||
}
|
||||
|
||||
private _clipMemoryContext(context: string): string {
|
||||
if (!context) {
|
||||
return context;
|
||||
|
||||
Reference in New Issue
Block a user