/** * Agentic tool-execution loop for the memory-flush reflection turn. * * Extracted from {@link runMemoryFlush} so the loop primitive is * testable in isolation and reusable from any reflection-style phase * that wants "invoke → execute tool_calls → feed results back → repeat". * * Design: * - Bounded iterations — caller passes `maxIterations` (default * {@link DEFAULT_MAX_FLUSH_ITERATIONS}). The loop exits as soon as * the model stops emitting `tool_calls`, hits the cap, or the reply * matches {@link SILENT_REPLY_TOKEN}. * - Rich result — returns iteration count, attempted/successful * appends, per-tool errors, silent-reply flag, and raw final text. * The caller logs this; nothing is swallowed. * - Self-contained tool execution — does not depend on LangGraph's * ToolNode or any graph runtime. The only contract is the * LangChain v0.3 `StructuredToolInterface.invoke(args)` shape. * - Tool errors are captured as {@link ToolMessage}s with the raw * JSON error, so the model can read them and self-correct (e.g. * retry with a different path if the schema rejected its first * attempt). */ import { AIMessage, type BaseMessage, ToolMessage, } from '@langchain/core/messages'; import type { StructuredToolInterface } from '@langchain/core/tools'; import { DEFAULT_MAX_FLUSH_ITERATIONS, MEMORY_APPEND_TOOL_NAME, SILENT_REPLY_TOKEN, } from '@/memory/constants'; /** Minimal model contract the loop needs. */ export interface InvokableModel { // eslint-disable-next-line @typescript-eslint/no-explicit-any invoke(messages: BaseMessage[], options?: any): Promise; } export interface FlushLoopParams { model: InvokableModel; tools: StructuredToolInterface[]; /** Initial messages — typically [SystemMessage, HumanMessage]. */ initialMessages: BaseMessage[]; /** Upper bound on model.invoke() calls. Default 8. */ maxIterations?: number; /** * Optional debug hook — called once per iteration with `{ i, ai, toolCalls }`. * Use for INFO-level tracing during rollout; pass `undefined` in prod. */ onIteration?: (event: { i: number; ai: AIMessage; toolCalls: ToolCallLike[]; }) => void; } /** Shape LangChain emits on AIMessage.tool_calls (duck-typed). */ export interface ToolCallLike { name: string; args: Record; id?: string; } export interface ToolErrorRecord { iteration: number; toolName: string; toolCallId?: string; error: string; /** Raw args the model passed — useful for diagnosing schema drift. */ args?: Record; } export interface FlushLoopResult { /** Number of model.invoke() calls actually made. */ iterations: number; /** Every `memory_append` tool_call the model emitted across all iterations. */ appendsAttempted: number; /** How many of those returned `{ ok: true, ... }`. */ appendsSucceeded: number; /** Tool errors the model saw (and may have reacted to). */ toolErrors: ToolErrorRecord[]; /** True if the final AIMessage text matched {@link SILENT_REPLY_TOKEN}. */ silentReply: boolean; /** Whether the loop stopped because it hit `maxIterations`. */ hitIterationCap: boolean; /** Final AIMessage content as plain text (best-effort). */ finalText: string; /** Full message transcript — useful for debugging / tests. */ messages: BaseMessage[]; } /** * Extract a flat text string from any AIMessage content shape * (string, array of content blocks, etc). */ export function extractText(message: AIMessage): string { const c = message.content; if (typeof c === 'string') return c; if (!Array.isArray(c)) return ''; return c .map((block) => { if (typeof block === 'string') return block; if (block && typeof block === 'object' && 'text' in block) { return String((block as { text?: unknown }).text ?? ''); } return ''; }) .join('') .trim(); } /** * Parse a tool result string. Memory tools return JSON with * `{ ok: boolean, error?: string, path?: string }`. Non-JSON payloads * are treated as opaque success strings. */ export function parseToolResult(raw: string): { ok: boolean; error?: string; path?: string; } { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && 'ok' in parsed) { return { ok: Boolean(parsed.ok), error: typeof parsed.error === 'string' ? parsed.error : undefined, path: typeof parsed.path === 'string' ? parsed.path : undefined, }; } return { ok: true }; } catch { return { ok: true }; } } /** * Bind a tool array to a model if `bindTools` exists on the model. * Exported so the caller (runMemoryFlush) can do it once and pass the * bound model in, keeping this loop free of LangChain model-specific * extensions. */ export function bindToolsIfSupported( // eslint-disable-next-line @typescript-eslint/no-explicit-any model: any, tools: StructuredToolInterface[] ): InvokableModel { if (typeof model?.bindTools === 'function') { return model.bindTools(tools) as InvokableModel; } return model as InvokableModel; } /** * Run the agentic reflection loop until the model stops emitting * tool_calls, the iteration cap is hit, or the final reply matches * {@link SILENT_REPLY_TOKEN}. */ export async function runFlushLoop( params: FlushLoopParams ): Promise { const { model, tools, initialMessages, maxIterations = DEFAULT_MAX_FLUSH_ITERATIONS, onIteration, } = params; const toolMap = new Map(); for (const t of tools) { toolMap.set(t.name, t); } const messages: BaseMessage[] = [...initialMessages]; const toolErrors: ToolErrorRecord[] = []; let appendsAttempted = 0; let appendsSucceeded = 0; let iterations = 0; let finalText = ''; let hitIterationCap = false; // Tracks whether the most recent iteration ended with unresolved // tool_calls. If we exit the while via the cap (not via the natural // `break`), this stays true and we flag hitIterationCap. let lastIterationHadPendingCalls = false; while (iterations < maxIterations) { iterations += 1; const ai = await model.invoke(messages); messages.push(ai); const toolCalls = (ai.tool_calls ?? []) as ToolCallLike[]; onIteration?.({ i: iterations, ai, toolCalls }); if (toolCalls.length === 0) { finalText = extractText(ai); lastIterationHadPendingCalls = false; break; } lastIterationHadPendingCalls = true; for (const call of toolCalls) { if (call.name === MEMORY_APPEND_TOOL_NAME) { appendsAttempted += 1; } const tool = toolMap.get(call.name); if (!tool) { const errStr = `tool_not_found: ${call.name}`; toolErrors.push({ iteration: iterations, toolName: call.name, toolCallId: call.id, error: errStr, args: call.args, }); messages.push( new ToolMessage({ content: JSON.stringify({ ok: false, error: errStr }), tool_call_id: call.id ?? '', name: call.name, }) ); continue; } let rawResult: string; try { // LangChain tools accept the parsed args object. // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await (tool as any).invoke(call.args); rawResult = typeof res === 'string' ? res : JSON.stringify(res); } catch (err) { const errStr = err instanceof Error ? err.message : String(err); toolErrors.push({ iteration: iterations, toolName: call.name, toolCallId: call.id, error: errStr, args: call.args, }); messages.push( new ToolMessage({ content: JSON.stringify({ ok: false, error: errStr }), tool_call_id: call.id ?? '', name: call.name, }) ); continue; } const parsed = parseToolResult(rawResult); if (call.name === MEMORY_APPEND_TOOL_NAME) { if (parsed.ok) { appendsSucceeded += 1; } else if (parsed.error) { toolErrors.push({ iteration: iterations, toolName: call.name, toolCallId: call.id, error: parsed.error, args: call.args, }); } } messages.push( new ToolMessage({ content: rawResult, tool_call_id: call.id ?? '', name: call.name, }) ); } } if (iterations >= maxIterations && lastIterationHadPendingCalls) { hitIterationCap = true; // Surface the last AIMessage text (if any) so callers can still log it. for (let i = messages.length - 1; i >= 0; i -= 1) { const m = messages[i]; if (m instanceof AIMessage) { finalText = extractText(m); break; } } } const silentReply = finalText.trim() === SILENT_REPLY_TOKEN; return { iterations, appendsAttempted, appendsSucceeded, toolErrors, silentReply, hitIterationCap, finalText, messages, }; }