feat: add proactive memory extraction and daily logs

This commit is contained in:
William Valentin
2026-02-18 10:10:47 -08:00
parent 9cbd66cdcc
commit f38fc063d2
8 changed files with 250 additions and 5 deletions
+62
View File
@@ -452,6 +452,68 @@ describe('AgentOrchestrator', () => {
getPromptSectionsSpy.mockRestore();
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 });
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,
memoryDailyLogEnabled: true,
memoryDailyLogNamespacePrefix: 'daily',
});
await orchestrator.process('Log this turn');
const date = new Date().toISOString().slice(0, 10);
const dailyLog = memoryStore.read(`daily/${date}`);
expect(dailyLog).toContain('Log this turn');
expect(dailyLog).toContain('default response');
expect(dailyLog).toContain('tool_calls: 0');
rmSync(tempDir, { recursive: true, force: true });
});
it('runs proactive per-turn extraction when enabled and threshold is met', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-proactive-extract-'));
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
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,
memoryProactiveExtractEnabled: true,
memoryProactiveExtractMinToolCalls: 0,
memoryProactiveExtractNamespace: 'global',
});
await orchestrator.process('Capture durable context');
const extracted = memoryStore.read('global');
expect(extracted).toContain('default response');
rmSync(tempDir, { recursive: true, force: true });
});
});
describe('compact()', () => {
+113 -3
View File
@@ -15,7 +15,7 @@ import { estimateCost } from '../../models/costs.js';
import { auditLogger } from '../../audit/index.js';
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js';
import { buildUserMessage } from '../../models/media.js';
import { CONTEXT_CHECKPOINT_PROMPT } from './prompts.js';
import { CONTEXT_CHECKPOINT_PROMPT, MEMORY_EXTRACTION_PROMPT } from './prompts.js';
// ── Public types ──────────────────────────────────────────────────────
@@ -123,6 +123,16 @@ export interface OrchestratorConfig {
memoryInjectionStrategy?: 'all' | 'recent' | 'adaptive';
/** Maximum tokens allowed for injected memory context. */
memoryMaxInjectionTokens?: number;
/** Enable per-turn proactive memory extraction (beyond compaction-time extraction). */
memoryProactiveExtractEnabled?: boolean;
/** Minimum tool calls in a turn to trigger proactive memory extraction. */
memoryProactiveExtractMinToolCalls?: number;
/** Namespace to append proactive extracted facts into. */
memoryProactiveExtractNamespace?: string;
/** Enable daily append-only memory logs of user/assistant turns. */
memoryDailyLogEnabled?: boolean;
/** Namespace prefix for daily logs (full namespace: <prefix>/YYYY-MM-DD). */
memoryDailyLogNamespacePrefix?: string;
/** Automatically retry failed primary runs on a higher tier. */
autoEscalate?: boolean;
/** Tier to try for auto-escalation retries. Defaults to complex. */
@@ -157,9 +167,16 @@ export class AgentOrchestrator {
private _memoryAutoExtract: boolean;
private _memoryInjectionStrategy: 'all' | 'recent' | 'adaptive';
private _memoryMaxInjectionTokens: number;
private _memoryProactiveExtractEnabled: boolean;
private _memoryProactiveExtractMinToolCalls: number;
private _memoryProactiveExtractNamespace: string;
private _memoryDailyLogEnabled: boolean;
private _memoryDailyLogNamespacePrefix: string;
private _autoEscalate: boolean;
private _autoEscalateTier: ModelTier;
private _systemPromptBase: string;
private _externalOnToolUse?: (event: ToolUseEvent) => void;
private _activeRunToolStarts = 0;
private _usageByTier: Map<string, TierUsageStats> = new Map();
private _lastContextAlertLevel: ContextAlertLevel | null = null;
private _pendingContextAlert?: ContextAlert;
@@ -178,9 +195,15 @@ export class AgentOrchestrator {
this._memoryAutoExtract = config.memoryAutoExtract ?? true;
this._memoryInjectionStrategy = config.memoryInjectionStrategy ?? 'all';
this._memoryMaxInjectionTokens = config.memoryMaxInjectionTokens ?? 2000;
this._memoryProactiveExtractEnabled = config.memoryProactiveExtractEnabled ?? false;
this._memoryProactiveExtractMinToolCalls = Math.max(0, config.memoryProactiveExtractMinToolCalls ?? 1);
this._memoryProactiveExtractNamespace = config.memoryProactiveExtractNamespace ?? 'global';
this._memoryDailyLogEnabled = config.memoryDailyLogEnabled ?? false;
this._memoryDailyLogNamespacePrefix = config.memoryDailyLogNamespacePrefix ?? 'daily';
this._autoEscalate = config.autoEscalate ?? false;
this._autoEscalateTier = config.autoEscalateTier ?? 'complex';
this._systemPromptBase = config.systemPrompt;
this._externalOnToolUse = config.onToolUse;
// Create the primary NativeAgent for user-facing conversation
this._agent = new NativeAgent({
@@ -190,7 +213,7 @@ export class AgentOrchestrator {
toolRegistry: config.toolRegistry,
toolExecutor: config.toolExecutor,
maxIterations: config.maxIterations,
onToolUse: config.onToolUse,
onToolUse: (event) => this._handleToolUse(event),
toolPolicyContext: config.toolPolicyContext,
attachmentCollector: config.attachmentCollector,
});
@@ -265,6 +288,7 @@ export class AgentOrchestrator {
* exceeds the context window threshold and compacts it before processing.
*/
async process(userMessage: string, attachments?: Attachment[]): Promise<string> {
this._activeRunToolStarts = 0;
this._injectMemoryContext(userMessage);
await this._runProactiveContextMaintenance();
await this.compactIfNeeded();
@@ -281,6 +305,7 @@ export class AgentOrchestrator {
this._restoreHistory(before);
const escalated = await this._retryWithEscalation(userMessage, attachments, before, originalTier);
if (escalated) {
await this._runPostTurnMemoryMaintenance(userMessage, escalated, this._activeRunToolStarts);
return escalated;
}
const friendly =
@@ -316,6 +341,7 @@ export class AgentOrchestrator {
const escalated = await this._retryWithEscalation(userMessage, attachments, before, originalTier);
if (escalated) {
await this._runPostTurnMemoryMaintenance(userMessage, escalated, this._activeRunToolStarts);
return escalated;
}
@@ -334,6 +360,7 @@ export class AgentOrchestrator {
return friendly;
}
await this._runPostTurnMemoryMaintenance(userMessage, result, this._activeRunToolStarts);
return result;
}
@@ -444,7 +471,7 @@ export class AgentOrchestrator {
/** Set the tool-use callback on the primary agent. */
setOnToolUse(callback: ((event: ToolUseEvent) => void) | undefined): void {
this._agent.setOnToolUse(callback);
this._externalOnToolUse = callback;
}
/** Request cancellation for the current primary-agent operation. */
@@ -595,6 +622,89 @@ export class AgentOrchestrator {
return context.slice(0, maxChars);
}
private _handleToolUse(event: ToolUseEvent): void {
if (event.type === 'start') {
this._activeRunToolStarts += 1;
}
this._externalOnToolUse?.(event);
}
private async _runPostTurnMemoryMaintenance(userMessage: string, assistantText: string, toolCallsInRun: number): Promise<void> {
if (!this._memoryStore) {
return;
}
if (this._memoryDailyLogEnabled) {
this._appendDailyLog(userMessage, assistantText, toolCallsInRun);
}
if (!this._memoryProactiveExtractEnabled || toolCallsInRun < this._memoryProactiveExtractMinToolCalls) {
return;
}
try {
const extractionTier = this.getDelegationTier('memory_extraction');
const extraction = await this.delegate({
tier: extractionTier,
systemPrompt: MEMORY_EXTRACTION_PROMPT,
message: this._buildExtractionInput(userMessage, assistantText, toolCallsInRun),
maxTokens: 512,
});
const extractedContent = extraction.content.trim();
if (!extractedContent || extractedContent.toLowerCase().includes('no facts')) {
return;
}
this._memoryStore.write(this._memoryProactiveExtractNamespace, extractedContent, 'append');
} catch (error) {
console.warn('[Flynn:memory] Proactive per-turn extraction failed:', error);
}
}
private _appendDailyLog(userMessage: string, assistantText: string, toolCallsInRun: number): void {
if (!this._memoryStore) {
return;
}
const date = new Date().toISOString().slice(0, 10);
const timestamp = new Date().toISOString();
const namespace = `${this._memoryDailyLogNamespacePrefix}/${date}`;
const user = this._truncateForMemory(userMessage, 2000);
const assistant = this._truncateForMemory(assistantText, 4000);
const block = [
`## ${timestamp}`,
'',
`- tool_calls: ${toolCallsInRun}`,
'',
'### User',
user,
'',
'### Assistant',
assistant,
'',
].join('\n');
this._memoryStore.write(namespace, block, 'append');
}
private _buildExtractionInput(userMessage: string, assistantText: string, toolCallsInRun: number): string {
const user = this._truncateForMemory(userMessage, 4000);
const assistant = this._truncateForMemory(assistantText, 6000);
return [
`Tool calls in this turn: ${toolCallsInRun}`,
'',
'User message:',
user,
'',
'Assistant response:',
assistant,
].join('\n');
}
private _truncateForMemory(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
return `${text.slice(0, maxChars)}...[truncated]`;
}
/**
* Check whether automatic compaction should run, and if so, compact.
* Called before each `process()` call when compaction is configured.