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.
|
||||
|
||||
@@ -1277,6 +1277,11 @@ describe('configSchema — memory injection strategy', () => {
|
||||
const result = configSchema.parse(minimalConfig);
|
||||
expect(result.memory.injection_strategy).toBe('all');
|
||||
expect(result.memory.max_injection_tokens).toBe(2000);
|
||||
expect(result.memory.proactive_extract.enabled).toBe(false);
|
||||
expect(result.memory.proactive_extract.min_tool_calls).toBe(1);
|
||||
expect(result.memory.proactive_extract.namespace).toBe('global');
|
||||
expect(result.memory.daily_log.enabled).toBe(false);
|
||||
expect(result.memory.daily_log.namespace_prefix).toBe('daily');
|
||||
expect(result.memory.qmd.enabled).toBe(false);
|
||||
expect(result.memory.qmd.top_k).toBe(8);
|
||||
expect(result.memory.qmd.min_score).toBe(0.15);
|
||||
@@ -1309,6 +1314,29 @@ describe('configSchema — memory injection strategy', () => {
|
||||
expect(result.memory.qmd.top_k).toBe(12);
|
||||
expect(result.memory.qmd.min_score).toBe(0.2);
|
||||
});
|
||||
|
||||
it('accepts proactive extraction and daily log settings', () => {
|
||||
const result = configSchema.parse({
|
||||
...minimalConfig,
|
||||
memory: {
|
||||
proactive_extract: {
|
||||
enabled: true,
|
||||
min_tool_calls: 3,
|
||||
namespace: 'global/facts',
|
||||
},
|
||||
daily_log: {
|
||||
enabled: true,
|
||||
namespace_prefix: 'memory',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.memory.proactive_extract.enabled).toBe(true);
|
||||
expect(result.memory.proactive_extract.min_tool_calls).toBe(3);
|
||||
expect(result.memory.proactive_extract.namespace).toBe('global/facts');
|
||||
expect(result.memory.daily_log.enabled).toBe(true);
|
||||
expect(result.memory.daily_log.namespace_prefix).toBe('memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configSchema — compaction importance threshold', () => {
|
||||
|
||||
@@ -516,6 +516,15 @@ const memorySchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
dir: z.string().optional(), // Default: ~/.local/share/flynn/memory
|
||||
auto_extract: z.boolean().default(true),
|
||||
proactive_extract: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
min_tool_calls: z.number().min(0).max(50).default(1),
|
||||
namespace: z.string().default('global'),
|
||||
}).default({}),
|
||||
daily_log: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
namespace_prefix: z.string().default('daily'),
|
||||
}).default({}),
|
||||
injection_strategy: z.enum(['all', 'recent', 'adaptive']).default('all'),
|
||||
max_injection_tokens: z.number().min(100).max(10000).default(2000),
|
||||
max_context_tokens: z.number().min(100).max(10000).default(2000),
|
||||
|
||||
@@ -315,6 +315,11 @@ export function createMessageRouter(deps: {
|
||||
memoryAutoExtract: deps.config.memory?.auto_extract,
|
||||
memoryInjectionStrategy: deps.config.memory?.injection_strategy,
|
||||
memoryMaxInjectionTokens: deps.config.memory?.max_injection_tokens,
|
||||
memoryProactiveExtractEnabled: deps.config.memory?.proactive_extract?.enabled,
|
||||
memoryProactiveExtractMinToolCalls: deps.config.memory?.proactive_extract?.min_tool_calls,
|
||||
memoryProactiveExtractNamespace: deps.config.memory?.proactive_extract?.namespace,
|
||||
memoryDailyLogEnabled: deps.config.memory?.daily_log?.enabled,
|
||||
memoryDailyLogNamespacePrefix: deps.config.memory?.daily_log?.namespace_prefix,
|
||||
autoEscalate: deps.config.agents.auto_escalate,
|
||||
autoEscalateTier: 'complex',
|
||||
toolPolicyContext,
|
||||
|
||||
@@ -313,6 +313,11 @@ export class SessionBridge {
|
||||
memoryAutoExtract: config?.memory?.auto_extract,
|
||||
memoryInjectionStrategy: config?.memory?.injection_strategy,
|
||||
memoryMaxInjectionTokens: config?.memory?.max_injection_tokens,
|
||||
memoryProactiveExtractEnabled: config?.memory?.proactive_extract?.enabled,
|
||||
memoryProactiveExtractMinToolCalls: config?.memory?.proactive_extract?.min_tool_calls,
|
||||
memoryProactiveExtractNamespace: config?.memory?.proactive_extract?.namespace,
|
||||
memoryDailyLogEnabled: config?.memory?.daily_log?.enabled,
|
||||
memoryDailyLogNamespacePrefix: config?.memory?.daily_log?.namespace_prefix,
|
||||
toolPolicyContext: {
|
||||
agent: primaryTier,
|
||||
provider: config?.models.default.provider,
|
||||
|
||||
Reference in New Issue
Block a user