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 { ToolRegistry, ToolExecutor } from '../../tools/index.js';
|
||||||
import { HookEngine } from '../../hooks/engine.js';
|
import { HookEngine } from '../../hooks/engine.js';
|
||||||
import { MemoryStore } from '../../memory/store.js';
|
import { MemoryStore } from '../../memory/store.js';
|
||||||
|
import { writeWorkingMemory } from '../../memory/workingMemory.js';
|
||||||
import type { Session } from '../../session/index.js';
|
import type { Session } from '../../session/index.js';
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
@@ -453,6 +454,49 @@ describe('AgentOrchestrator', () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
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 () => {
|
it('appends daily memory log entries when enabled', async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-daily-log-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-daily-log-'));
|
||||||
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
|
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
|
||||||
@@ -640,6 +684,54 @@ describe('AgentOrchestrator', () => {
|
|||||||
initAuditLogger(previousAuditLogger as unknown as AuditLogger);
|
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()', () => {
|
describe('reset()', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_C
|
|||||||
import { estimateCost } from '../../models/costs.js';
|
import { estimateCost } from '../../models/costs.js';
|
||||||
import { auditLogger } from '../../audit/index.js';
|
import { auditLogger } from '../../audit/index.js';
|
||||||
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js';
|
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js';
|
||||||
|
import { readWorkingMemory, writeWorkingMemory } from '../../memory/workingMemory.js';
|
||||||
import { buildUserMessage } from '../../models/media.js';
|
import { buildUserMessage } from '../../models/media.js';
|
||||||
import { CONTEXT_CHECKPOINT_PROMPT, MEMORY_EXTRACTION_PROMPT } from './prompts.js';
|
import { CONTEXT_CHECKPOINT_PROMPT, MEMORY_EXTRACTION_PROMPT } from './prompts.js';
|
||||||
|
|
||||||
@@ -157,6 +158,14 @@ export interface OrchestratorConfig {
|
|||||||
toolPolicyContext?: ToolPolicyContext;
|
toolPolicyContext?: ToolPolicyContext;
|
||||||
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
||||||
attachmentCollector?: OutboundAttachmentCollector;
|
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 ─────────────────────────────────────────────────
|
// ── AgentOrchestrator ─────────────────────────────────────────────────
|
||||||
@@ -196,9 +205,14 @@ export class AgentOrchestrator {
|
|||||||
private _memoryDailyLogIncludeSessionMetadata: boolean;
|
private _memoryDailyLogIncludeSessionMetadata: boolean;
|
||||||
private _memoryDailyLogMaxUserChars: number;
|
private _memoryDailyLogMaxUserChars: number;
|
||||||
private _memoryDailyLogMaxAssistantChars: number;
|
private _memoryDailyLogMaxAssistantChars: number;
|
||||||
|
private _userNamespace?: string;
|
||||||
|
private _workingMemoryTtlDays: number;
|
||||||
|
private _workingMemoryMaxTokens: number;
|
||||||
|
private _proactiveSessionGreeting: boolean;
|
||||||
private _autoEscalate: boolean;
|
private _autoEscalate: boolean;
|
||||||
private _autoEscalateTier: ModelTier;
|
private _autoEscalateTier: ModelTier;
|
||||||
private _systemPromptBase: string;
|
private _systemPromptBase: string;
|
||||||
|
private _sessionContext: string | null = null;
|
||||||
private _externalOnToolUse?: (event: ToolUseEvent) => void;
|
private _externalOnToolUse?: (event: ToolUseEvent) => void;
|
||||||
private _activeRunToolStarts = 0;
|
private _activeRunToolStarts = 0;
|
||||||
private _usageByTier: Map<string, TierUsageStats> = new Map();
|
private _usageByTier: Map<string, TierUsageStats> = new Map();
|
||||||
@@ -228,6 +242,10 @@ export class AgentOrchestrator {
|
|||||||
this._memoryDailyLogIncludeSessionMetadata = config.memoryDailyLogIncludeSessionMetadata ?? true;
|
this._memoryDailyLogIncludeSessionMetadata = config.memoryDailyLogIncludeSessionMetadata ?? true;
|
||||||
this._memoryDailyLogMaxUserChars = Math.max(100, config.memoryDailyLogMaxUserChars ?? 2000);
|
this._memoryDailyLogMaxUserChars = Math.max(100, config.memoryDailyLogMaxUserChars ?? 2000);
|
||||||
this._memoryDailyLogMaxAssistantChars = Math.max(100, config.memoryDailyLogMaxAssistantChars ?? 4000);
|
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._autoEscalate = config.autoEscalate ?? false;
|
||||||
this._autoEscalateTier = config.autoEscalateTier ?? 'complex';
|
this._autoEscalateTier = config.autoEscalateTier ?? 'complex';
|
||||||
this._systemPromptBase = config.systemPrompt;
|
this._systemPromptBase = config.systemPrompt;
|
||||||
@@ -346,6 +364,7 @@ export class AgentOrchestrator {
|
|||||||
turnAudioInput?: NativeAgentTurnAudioInput,
|
turnAudioInput?: NativeAgentTurnAudioInput,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this._activeRunToolStarts = 0;
|
this._activeRunToolStarts = 0;
|
||||||
|
this._buildSessionContext();
|
||||||
this._injectMemoryContext(userMessage);
|
this._injectMemoryContext(userMessage);
|
||||||
await this._runProactiveContextMaintenance();
|
await this._runProactiveContextMaintenance();
|
||||||
await this.compactIfNeeded();
|
await this.compactIfNeeded();
|
||||||
@@ -473,6 +492,8 @@ export class AgentOrchestrator {
|
|||||||
config,
|
config,
|
||||||
memoryStore: this._memoryStore,
|
memoryStore: this._memoryStore,
|
||||||
autoExtract: this._memoryAutoExtract,
|
autoExtract: this._memoryAutoExtract,
|
||||||
|
usePersonalAssistantPrompt: Boolean(this._userNamespace),
|
||||||
|
memoryExtractionNamespace: this._userNamespace ? `${this._userNamespace}/facts` : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If nothing was actually compacted, skip the replace
|
// 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +544,7 @@ export class AgentOrchestrator {
|
|||||||
this._lastContextAlertLevel = null;
|
this._lastContextAlertLevel = null;
|
||||||
this._pendingContextAlert = undefined;
|
this._pendingContextAlert = undefined;
|
||||||
this._lastCheckpointAt = 0;
|
this._lastCheckpointAt = 0;
|
||||||
|
this._sessionContext = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the primary agent's conversation history. */
|
/** Get the primary agent's conversation history. */
|
||||||
@@ -649,6 +684,9 @@ export class AgentOrchestrator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveBase = this._sessionContext
|
||||||
|
? `${this._systemPromptBase}\n\n${this._sessionContext}`
|
||||||
|
: this._systemPromptBase;
|
||||||
let memoryContext = '';
|
let memoryContext = '';
|
||||||
try {
|
try {
|
||||||
if (this._memoryInjectionStrategy === 'recent') {
|
if (this._memoryInjectionStrategy === 'recent') {
|
||||||
@@ -673,14 +711,55 @@ export class AgentOrchestrator {
|
|||||||
memoryContext = this._clipMemoryContext(memoryContext);
|
memoryContext = this._clipMemoryContext(memoryContext);
|
||||||
|
|
||||||
if (!memoryContext) {
|
if (!memoryContext) {
|
||||||
this._agent.setSystemPrompt(this._systemPromptBase);
|
this._agent.setSystemPrompt(effectiveBase);
|
||||||
return;
|
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);
|
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 {
|
private _clipMemoryContext(context: string): string {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
Reference in New Issue
Block a user