/** * Memory flush phase — trigger logic + reflection invocation. * * Ported from upstream's post-turn flush handler. The agent is re-invoked * with a reflection system prompt and the `memory_append` tool unlocked; * it writes notes to its future self, then the graph returns to normal. * * This module is INTENTIONALLY decoupled from the specific graph runtime — * it exposes pure functions (`shouldFlushMemory`) plus a runner that takes * the model as a parameter, so the same logic works from `createAgentNode`, * `MultiAgentGraph`, or a future graph backend that wants to override * flush behaviour. * * The agentic tool-execution loop (invoke → run tool_calls → feed results * back → repeat until stop) lives in {@link ./flushLoop}. This module wires * it up: build tools, resolve prompts, flip the phase, call the loop, * shape the rich result. */ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DEFAULT_FLUSH_RESERVE_FLOOR_TOKENS, DEFAULT_FLUSH_SOFT_THRESHOLD_TOKENS, DEFAULT_MAX_FLUSH_ITERATIONS, MEMORY_PHASE_FLUSHING, MEMORY_PHASE_NORMAL, } from '@/memory/constants'; import type { MemoryConfig } from '@/memory/types'; import { buildMemoryTools } from '@/tools/memory'; import { resolveFlushPrompts } from '@/prompts/memoryFlushPrompt'; import { bindToolsIfSupported, runFlushLoop, type FlushLoopResult, type ToolErrorRecord, } from './flushLoop'; export interface ShouldFlushInput { currentTokens: number; windowTokens: number; reserveFloorTokens?: number; softThresholdTokens?: number; } /** * Pure trigger function: fires when the current context is within * `softThreshold + reserveFloor` tokens of the model window. Matches * upstream's formula. */ export function shouldFlushMemory(input: ShouldFlushInput): boolean { if ( !Number.isFinite(input.currentTokens) || !Number.isFinite(input.windowTokens) ) { return false; } if (input.windowTokens <= 0) return false; const reserve = input.reserveFloorTokens ?? DEFAULT_FLUSH_RESERVE_FLOOR_TOKENS; const soft = input.softThresholdTokens ?? DEFAULT_FLUSH_SOFT_THRESHOLD_TOKENS; return input.currentTokens >= input.windowTokens - reserve - soft; } export interface RunFlushParams { model: BaseChatModel; memory: MemoryConfig; /** A compact summary of the conversation — last N turns, not raw history. */ conversationSummary: string; /** * Accessor the graph runtime uses to expose the current phase to the * append tool. The runner sets this to `memory_flushing` for the duration * of the reflection turn and restores it on exit. */ setPhase: ( phase: typeof MEMORY_PHASE_NORMAL | typeof MEMORY_PHASE_FLUSHING ) => void; /** * @deprecated No longer used — the 8-path canonical-document model * does not date-stamp files. Kept in the params shape for one release * so host's callers don't break on the type signature. */ timezone?: string; /** * @deprecated Same as `timezone` — unused in the canonical-document model. */ nowMs?: number; /** Override for the agentic-loop iteration cap. */ maxIterations?: number; /** * Optional per-iteration callback for structured debug logging. * Caller is expected to demote this to debug once the rollout is stable. */ onIteration?: (event: { i: number; toolCallCount: number; toolNames: string[]; }) => void; } export interface RunFlushResult { /** Did the flush actually run (false if disabled via config). */ ran: boolean; /** Model.invoke() iterations actually performed. */ iterations?: number; /** Total `memory_append` calls the model emitted. */ appendsAttempted?: number; /** Subset that returned `{ ok: true }` from the backend. */ appendsSucceeded?: number; /** Tool errors the model saw during the flush — surfaced for logging. */ toolErrors?: ToolErrorRecord[]; /** Final text reply equalled SILENT_REPLY_TOKEN (`NO_REPLY`). */ silentReply?: boolean; /** Loop hit its iteration cap with tool_calls still pending. */ hitIterationCap?: boolean; /** Final text reply from the last AIMessage. */ finalText?: string; /** Error message if the flush threw. */ error?: string; } /** * Run the reflection turn. The model is re-invoked with just the flush * prompt + compact summary + the append tool unlocked. We intentionally * drop the full history — the prompt tells the agent to write notes based * on what it learned, not to continue the conversation. * * The loop keeps invoking the model until it stops emitting tool_calls, * hits the iteration cap, or replies with {@link SILENT_REPLY_TOKEN}. Each * `memory_append` tool_call is executed against the pgvector backend and * the result (success path or error) is fed back as a ToolMessage so the * model can self-correct (e.g. retry with a valid path). */ export async function runMemoryFlush( params: RunFlushParams ): Promise { const { model, memory, conversationSummary, setPhase, maxIterations, onIteration, } = params; if (memory.flush?.enabled === false) { return { ran: false }; } setPhase(MEMORY_PHASE_FLUSHING); try { const tools = buildMemoryTools({ ...memory, readEnabled: false, writeEnabled: true, getPhase: () => MEMORY_PHASE_FLUSHING, }); // Bind tools to the caller-provided model. If the model already came // bound (some graph wrappers do), bindToolsIfSupported is a no-op. const bound = bindToolsIfSupported(model, tools); // Resolve flush prompts with the scope-aware rubric. For an // isolated agent (no userId in scope), the user-tier paths are // filtered out of the rubric so the LLM never sees them — it // physically cannot route a write to `memory/user/*` because the // prompt never lists those paths. const { systemPrompt, prompt } = resolveFlushPrompts({ scope: memory.scope, }); const initialMessages = [ new SystemMessage(systemPrompt), new HumanMessage( `${prompt}\n\nConversation context:\n\n${conversationSummary}` ), ]; const loop: FlushLoopResult = await runFlushLoop({ model: bound, tools, initialMessages, maxIterations: maxIterations ?? DEFAULT_MAX_FLUSH_ITERATIONS, onIteration: onIteration ? (ev): void => onIteration({ i: ev.i, toolCallCount: ev.toolCalls.length, toolNames: ev.toolCalls.map((c) => c.name), }) : undefined, }); return { ran: true, iterations: loop.iterations, appendsAttempted: loop.appendsAttempted, appendsSucceeded: loop.appendsSucceeded, toolErrors: loop.toolErrors, silentReply: loop.silentReply, hitIterationCap: loop.hitIterationCap, finalText: loop.finalText, }; } catch (err) { return { ran: false, error: err instanceof Error ? err.message : String(err), }; } finally { setPhase(MEMORY_PHASE_NORMAL); } }