feat: add proactive memory extraction and daily logs
This commit is contained in:
@@ -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()', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user