const config = require("../config"); const { invokeModel } = require("../clients/databricks"); const { appendTurnToSession } = require("../sessions/record"); const { upsertSession } = require("../sessions/store"); const policy = require("../policy"); const logger = require("../logger"); const promptCache = require("../cache/prompt"); const tokens = require("../utils/tokens"); const systemPrompt = require("../prompts/system"); const historyCompression = require("../context/compression"); const tokenBudget = require("../context/budget"); const { applyToonCompression } = require("../context/toon"); const { applyGcfCompression } = require("../context/gcf"); const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom"); const { createAuditLogger } = require("../logger/audit-logger"); const { getShuttingDown } = require("../api/health"); const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight"); const { detectBypass, buildBypassResponse } = require("./bypass"); const crypto = require("crypto"); const { getSemanticCache } = require("../cache/semantic"); const { areSimilarToolCalls } = require("../clients/gpt-utils"); const { getModelRegistrySync } = require("../routing/model-registry"); const sessionAffinity = require("../routing/session-affinity"); /** * Get destination URL for audit logging based on provider type * @param {string} providerType - Provider type (databricks, azure-anthropic, etc) * @returns {string} - Destination URL */ function getDestinationUrl(providerType) { switch (providerType) { case 'databricks': return config.databricks?.url ?? 'unknown'; case 'azure-anthropic': return config.azureAnthropic?.endpoint ?? 'unknown'; case 'ollama': return config.ollama?.endpoint ?? 'unknown'; case 'azure-openai': return config.azureOpenAI?.endpoint ?? 'unknown'; case 'openrouter': return config.openrouter?.endpoint ?? 'unknown'; case 'edenai': return config.edenai?.endpoint ?? 'unknown'; case 'openai': return config.openai?.endpoint ?? 'https://api.openai.com/v1/chat/completions'; case 'atlas': return config.atlas?.endpoint ?? 'https://api.atlascloud.ai/v1/chat/completions'; case 'llamacpp': return config.llamacpp?.endpoint ?? 'unknown'; case 'lmstudio': return config.lmstudio?.endpoint ?? 'unknown'; case 'bedrock': return config.bedrock?.endpoint ?? 'unknown'; case 'zai': return config.zai?.endpoint ?? 'unknown'; case 'vertex': return config.vertex?.endpoint ?? 'unknown'; case 'moonshot': return config.moonshot?.endpoint ?? 'unknown'; case 'baidu': return config.baidu?.endpoint ?? 'unknown'; case 'codex': return 'codex://app-server (local process)'; default: return 'unknown'; } } const DROP_KEYS = new Set([ "provider", "api_type", "beta", "context_management", "stream", "max_steps", "max_duration_ms", ]); const DEFAULT_AZURE_TOOLS = Object.freeze([ { name: "WebSearch", input_schema: { type: "object", properties: { query: { type: "string", description: "Search query to execute.", }, }, required: ["query"], additionalProperties: false, }, }, { name: "WebFetch", input_schema: { type: "object", properties: { url: { type: "string", description: "URL to fetch.", }, prompt: { type: "string", description: "Optional summarisation prompt.", }, }, required: ["url"], additionalProperties: false, }, }, { name: "Bash", input_schema: { type: "object", properties: { command: { type: "string", description: "Shell command to execute.", }, timeout: { type: "integer", description: "Optional timeout in milliseconds.", }, }, required: ["command"], additionalProperties: false, }, }, { name: "BashOutput", input_schema: { type: "object", properties: { bash_id: { type: "string", description: "Identifier of the background bash process.", }, }, required: ["bash_id"], additionalProperties: false, }, }, { name: "KillShell", input_schema: { type: "object", properties: { shell_id: { type: "string", description: "Identifier of the background shell to terminate.", }, }, required: ["shell_id"], additionalProperties: false, }, }, ]); const PLACEHOLDER_WEB_RESULT_REGEX = /^Web search results for query:/i; function flattenBlocks(blocks) { if (!Array.isArray(blocks)) return String(blocks ?? ""); return blocks .map((block) => { if (!block) return ""; if (typeof block === "string") return block; if (block.type === "text" && typeof block.text === "string") return block.text; if (block.type === "tool_result") { const payload = block?.content ?? ""; return typeof payload === "string" ? payload : JSON.stringify(payload); } if (block.input_text) return block.input_text; return ""; }) .join(""); } function normaliseMessages(payload, options = {}) { const flattenContent = options.flattenContent !== false; const normalised = []; if (Array.isArray(payload.system) && payload.system.length) { const text = flattenBlocks(payload.system).trim(); if (text) normalised.push({ role: "system", content: text }); } if (Array.isArray(payload.messages)) { for (const message of payload.messages) { if (!message) continue; const role = message.role ?? "user"; const rawContent = message.content; let content; if (Array.isArray(rawContent)) { const hasToolBlocks = rawContent.some( (b) => b && (b.type === "tool_use" || b.type === "tool_result" || b.type === "document" || b.type === "image" || b.type === "thinking") ); if (hasToolBlocks) { content = rawContent.slice(); } else { content = flattenContent ? flattenBlocks(rawContent) : rawContent.slice(); } } else if (rawContent === undefined || rawContent === null) { content = flattenContent ? "" : rawContent; } else if (typeof rawContent === "string") { content = rawContent; } else if (flattenContent) { content = String(rawContent); } else { content = rawContent; } const entry = { role, content }; if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { entry.tool_calls = message.tool_calls; } normalised.push(entry); } } return normalised; } function normaliseTools(tools) { if (!Array.isArray(tools) || tools.length === 0) return undefined; return tools.map((tool) => ({ type: "function", function: { name: tool.name || "unnamed_tool", description: tool.description || tool.name || "No description provided", parameters: tool.input_schema ?? {}, }, })); } /** * Ensure tools are in Anthropic format for Databricks/Claude API * Databricks expects: {name, description, input_schema} * NOT OpenAI format: {type: "function", function: {...}} */ function ensureAnthropicToolFormat(tools) { if (!Array.isArray(tools) || tools.length === 0) return undefined; return tools.map((tool) => { // Shape-detect: unwrap OpenAI-format tools rather than fabricating // "unnamed_tool" from their absent top-level fields. if (tool?.type === "function" && tool.function) { tool = { name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters, }; } // Ensure input_schema has required 'type' field let input_schema = tool.input_schema || { type: "object", properties: {} }; if (input_schema && !input_schema.type) { input_schema = { type: "object", ...input_schema }; } return { name: tool.name || "unnamed_tool", description: tool.description || tool.name || "No description provided", input_schema, }; }); } function stripPlaceholderWebSearchContent(message) { if (!message || message.content === undefined || message.content === null) { return message; } if (typeof message.content === "string") { return PLACEHOLDER_WEB_RESULT_REGEX.test(message.content.trim()) ? null : message; } if (!Array.isArray(message.content)) { return message; } const filtered = message.content.filter((block) => { if (!block) return false; if (block.type === "tool_result") { const content = typeof block.content === "string" ? block.content.trim() : ""; if (PLACEHOLDER_WEB_RESULT_REGEX.test(content)) { return false; } } if (block.type === "text" && typeof block.text === "string") { if (PLACEHOLDER_WEB_RESULT_REGEX.test(block.text.trim())) { return false; } } return true; }); if (filtered.length === 0) { return null; } if (filtered.length === message.content.length) { return message; } return { ...message, content: filtered, }; } function isPlaceholderToolResultMessage(message) { if (!message) return false; if (message.role !== "user" && message.role !== "tool") return false; if (typeof message.content === "string") { return PLACEHOLDER_WEB_RESULT_REGEX.test(message.content.trim()); } if (!Array.isArray(message.content) || message.content.length === 0) { return false; } return message.content.every((block) => { if (!block || block.type !== "tool_result") return false; const text = typeof block.content === "string" ? block.content.trim() : ""; return PLACEHOLDER_WEB_RESULT_REGEX.test(text); }); } function removeMatchingAssistantToolUse(cleanMessages, toolUseId) { if (!toolUseId || cleanMessages.length === 0) return; const lastIndex = cleanMessages.length - 1; const candidate = cleanMessages[lastIndex]; if (!candidate || candidate.role !== "assistant") return; if (Array.isArray(candidate.content)) { const remainingBlocks = candidate.content.filter((block) => { if (!block || block.type !== "tool_use") return true; return block.id !== toolUseId; }); if (remainingBlocks.length === 0) { cleanMessages.pop(); } else if (remainingBlocks.length !== candidate.content.length) { cleanMessages[lastIndex] = { ...candidate, content: remainingBlocks, }; } return; } if (Array.isArray(candidate.tool_calls)) { const remainingCalls = candidate.tool_calls.filter((call) => call.id !== toolUseId); if (remainingCalls.length === 0) { cleanMessages.pop(); } else if (remainingCalls.length !== candidate.tool_calls.length) { cleanMessages[lastIndex] = { ...candidate, tool_calls: remainingCalls, }; } } } function normaliseToolIdentifier(name = "") { return String(name).toLowerCase().replace(/[^a-z0-9]/g, ""); } /** * Count tool_use and tool_result blocks in message history. * Only counts tools from the CURRENT TURN (after the last user text message). * This prevents the guard from blocking new questions after a previous loop. */ function countToolCallsInHistory(messages) { if (!Array.isArray(messages)) return { toolUseCount: 0, toolResultCount: 0 }; // Find the index of the last user message that contains actual text (not just tool_result) let lastUserTextIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role !== 'user') continue; // Check if this user message has actual text content (not just tool_result) if (typeof msg.content === 'string' && msg.content.trim().length > 0) { lastUserTextIndex = i; break; } if (Array.isArray(msg.content)) { const hasText = msg.content.some(block => (block?.type === 'text' && block?.text?.trim?.().length > 0) || (block?.type === 'input_text' && block?.input_text?.trim?.().length > 0) ); if (hasText) { lastUserTextIndex = i; break; } } } // Count only tool_use/tool_result AFTER the last user text message let toolUseCount = 0; let toolResultCount = 0; const startIndex = lastUserTextIndex >= 0 ? lastUserTextIndex : 0; for (let i = startIndex; i < messages.length; i++) { const msg = messages[i]; if (!msg || !Array.isArray(msg.content)) continue; for (const block of msg.content) { if (block?.type === 'tool_use') toolUseCount++; if (block?.type === 'tool_result') toolResultCount++; } } return { toolUseCount, toolResultCount, lastUserTextIndex }; } // === CROSS-REQUEST TOOL CALL DEDUP TRACKING === // These helpers track tool call signatures across multiple HTTP requests within // the same session (client/passthrough mode). The inner-loop detection in // runAgentLoop() only sees one request at a time, so repeated calls across // requests escape it. const DEDUP_MAX_SIGNATURES = 50; const DEDUP_WARN_THRESHOLD = 5; const DEDUP_TERMINATE_THRESHOLD = 8; /** * State-management tools are called repeatedly with similar args BY DESIGN — * that's their job, not a loop. Opencode calls `todowrite` after every step; * Claude Code uses `TodoWrite`; OpenWorker uses `todo_write`/`propose_plan`. * Counting them in the dedup tracker produced false-positive loop warnings * and force-terminations on healthy agent sessions (live incident: an * opencode analysis run was force-terminated into a raw tool-result dump * because its todo updates hit the terminate threshold). * @param {Object} toolCall - tool_use block * @returns {boolean} */ function isDedupExemptTool(toolCall) { const raw = toolCall?.function?.name ?? toolCall?.name ?? ''; const name = String(raw).toLowerCase().replace(/[_-]/g, ''); return name.includes('todo') || name === 'updateplan' || name === 'proposeplan'; } /** * Initialise session.metadata.toolCallDedup if missing. * @param {Object} session */ function ensureDedupStructure(session) { if (!session || !session.metadata) return; if (!session.metadata.toolCallDedup) { session.metadata.toolCallDedup = { signatures: {}, similarGroups: {}, seenIds: {}, lastResetAt: Date.now(), warningInjected: false, }; } } /** * Record a tool call into the cross-request dedup tracker. * Handles similarity merging and enforces the 50-entry cap. * @param {Object} session * @param {Object} toolCall - tool_use block (Anthropic format: { name, input, id }) */ function recordCrossRequestToolCall(session, toolCall) { if (!session?.metadata) return; if (isDedupExemptTool(toolCall)) return; // repeat-by-design tools never count toward loop detection ensureDedupStructure(session); const dedup = session.metadata.toolCallDedup; // Stateless HTTP agents (opencode, Cursor, OpenWorker) replay the FULL // conversation history on every agent-loop step, so the same tool_use block // arrives again on step 2, 3, 4… Counting each replay made counts grow // quadratically with turn length — any exploration past ~5 steps hit the // terminate threshold regardless of what it was doing (live incident: // a 7-step opencode code-trace was force-terminated as a "loop"). // Each tool_use block carries a unique id: count each id exactly once. const blockId = toolCall?.id; if (blockId) { if (!dedup.seenIds) dedup.seenIds = {}; if (dedup.seenIds[blockId]) return; // already counted this exact call dedup.seenIds[blockId] = 1; // Bounded: reset wipes this on each new user question; cap as a backstop. if (Object.keys(dedup.seenIds).length > 500) dedup.seenIds = { [blockId]: 1 }; } const signature = getToolCallSignature(toolCall); const toolName = toolCall.function?.name ?? toolCall.name ?? 'unknown'; const args = toolCall.function?.arguments ?? toolCall.input; const argsPreview = (typeof args === 'string' ? args : JSON.stringify(args ?? {})).substring(0, 200); const now = Date.now(); // Check if this signature maps to a canonical via similarity groups const canonicalSig = dedup.similarGroups[signature] || signature; if (dedup.signatures[canonicalSig]) { dedup.signatures[canonicalSig].count += 1; dedup.signatures[canonicalSig].lastSeen = now; } else { // Check for similar existing entries before creating a new one let mergedInto = null; for (const [existingSig, existingData] of Object.entries(dedup.signatures)) { // Build a fake call object from stored data to compare with areSimilarToolCalls const existingCall = { name: existingData.toolName, input: existingData.argsPreview, }; if (areSimilarToolCalls(toolCall, existingCall)) { // Merge: map this signature to the existing canonical dedup.similarGroups[signature] = existingSig; dedup.signatures[existingSig].count += 1; dedup.signatures[existingSig].lastSeen = now; mergedInto = existingSig; logger.debug({ newSignature: signature, canonicalSignature: existingSig, toolName, count: dedup.signatures[existingSig].count, }, "Cross-request tool dedup: merged similar call"); break; } } if (!mergedInto) { dedup.signatures[signature] = { count: 1, toolName, firstSeen: now, lastSeen: now, argsPreview, }; } } // Enforce cap: evict oldest entries if over limit const sigKeys = Object.keys(dedup.signatures); if (sigKeys.length > DEDUP_MAX_SIGNATURES) { const sorted = sigKeys.sort( (a, b) => dedup.signatures[a].lastSeen - dedup.signatures[b].lastSeen ); const toRemove = sorted.slice(0, sigKeys.length - DEDUP_MAX_SIGNATURES); for (const key of toRemove) { delete dedup.signatures[key]; // Also clean up any similarGroups pointing to this key for (const [groupSig, canonical] of Object.entries(dedup.similarGroups)) { if (canonical === key) delete dedup.similarGroups[groupSig]; } } } } /** * Return the highest dedup count, the associated tool name, and signature. * @param {Object} session * @returns {{ maxCount: number, toolName: string|null, signature: string|null }} */ function getMaxDedupCount(session) { if (!session?.metadata?.toolCallDedup?.signatures) { return { maxCount: 0, toolName: null, signature: null }; } const sigs = session.metadata.toolCallDedup.signatures; let maxCount = 0; let toolName = null; let signature = null; for (const [sig, data] of Object.entries(sigs)) { if (data.count > maxCount) { maxCount = data.count; toolName = data.toolName; signature = sig; } } return { maxCount, toolName, signature }; } /** * Extract tool_use blocks from messages that appear after the last user text message. * These are the tool calls from the current assistant turn that the client is sending back. * @param {Array} messages * @returns {Array} - Array of tool_use-like objects */ function extractToolUseFromCurrentTurn(messages) { if (!Array.isArray(messages)) return []; let lastUserTextIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role !== 'user') continue; if (typeof msg.content === 'string' && msg.content.trim().length > 0) { lastUserTextIndex = i; break; } if (Array.isArray(msg.content)) { const hasText = msg.content.some(block => (block?.type === 'text' && block?.text?.trim?.().length > 0) || (block?.type === 'input_text' && block?.input_text?.trim?.().length > 0) ); if (hasText) { lastUserTextIndex = i; break; } } } const toolUseBlocks = []; const startIndex = lastUserTextIndex >= 0 ? lastUserTextIndex : 0; for (let i = startIndex; i < messages.length; i++) { const msg = messages[i]; if (msg?.role !== 'assistant') continue; if (!Array.isArray(msg.content)) continue; for (const block of msg.content) { if (block?.type === 'tool_use') { toolUseBlocks.push(block); } } } return toolUseBlocks; } /** * Reset dedup tracking. Called when a new user question is detected. * @param {Object} session */ function resetDedupTracking(session) { if (!session?.metadata) return; session.metadata.toolCallDedup = { signatures: {}, similarGroups: {}, seenIds: {}, lastResetAt: Date.now(), warningInjected: false, }; logger.debug({ sessionId: session?.id ?? null }, "Cross-request tool dedup: reset tracking for new user question"); } function sanitiseAzureTools(tools) { if (!Array.isArray(tools) || tools.length === 0) return undefined; const allowed = new Set([ "WebSearch", "Web_Search", "websearch", "web_search", "web-fetch", "webfetch", "web_fetch", "bash", "shell", "bash_output", "bashoutput", "kill_shell", "killshell", ]); const cleaned = new Map(); for (const tool of tools) { if (!tool || typeof tool !== "object") continue; const rawName = typeof tool.name === "string" ? tool.name.trim() : ""; if (!rawName) continue; const identifier = normaliseToolIdentifier(rawName); if (!allowed.has(identifier)) continue; if (cleaned.has(identifier)) continue; let schema = null; if (tool.input_schema && typeof tool.input_schema === "object") { schema = tool.input_schema; } else if (tool.parameters && typeof tool.parameters === "object") { schema = tool.parameters; } if (!schema || typeof schema !== "object") { schema = { type: "object" }; } cleaned.set(identifier, { name: rawName, input_schema: schema, }); } return cleaned.size > 0 ? Array.from(cleaned.values()) : undefined; } function normaliseToolChoice(choice) { if (!choice) return undefined; if (typeof choice === "string") return choice; // "auto", "none" if (choice.type === "tool" && choice.name) { return { type: "function", function: { name: choice.name } }; } return undefined; } /** * Convert legacy Ollama /api/chat response to Anthropic Messages format. * Used when Ollama < v0.14.0 (no native Anthropic endpoint). * * Critical for MiniMax M2/M2.5 (and other interleaved-thinking models): * preserve ... from message.content AND Ollama's native * message.thinking field as Anthropic thinking blocks. Dropping them breaks * the model's long-horizon agent loop — vendor-quantified at Tau^2 -35.9%, * BrowseComp -40.1% (https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2). */ function ollamaToAnthropicResponse(ollamaResponse, requestedModel) { const message = ollamaResponse?.message ?? {}; const rawContent = typeof message.content === "string" ? message.content : ""; const nativeThinking = typeof message.thinking === "string" ? message.thinking : ""; const toolCalls = message.tool_calls || []; // Extract ... blocks from content (concatenate if multiple). // What remains becomes the text body. const thinkRegex = /([\s\S]*?)<\/think>/g; const thinkMatches = []; let textBody = rawContent; let m; while ((m = thinkRegex.exec(rawContent)) !== null) { thinkMatches.push(m[1]); } textBody = textBody.replace(thinkRegex, "").trim(); const combinedThinking = [nativeThinking, ...thinkMatches] .map(s => (s || "").trim()) .filter(Boolean) .join("\n\n"); const contentItems = []; // 1. Thinking block FIRST (Mini-Agent reference order: thinking → text → tool_use) if (combinedThinking) { contentItems.push({ type: "thinking", thinking: combinedThinking }); } // 2. Text body (after tags removed) if (textBody) { contentItems.push({ type: "text", text: textBody }); } // 3. Tool calls converted to Anthropic tool_use if (Array.isArray(toolCalls) && toolCalls.length > 0) { for (const toolCall of toolCalls) { const func = toolCall.function || {}; let input = {}; if (func.arguments) { if (typeof func.arguments === "string") { try { input = JSON.parse(func.arguments); } catch { input = {}; } } else if (typeof func.arguments === "object") { input = func.arguments; } } contentItems.push({ type: "tool_use", id: toolCall.id || `toolu_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, name: func.name || "unknown", input, }); } } if (contentItems.length === 0) { contentItems.push({ type: "text", text: "" }); } const inputTokens = ollamaResponse.prompt_eval_count ?? 0; const outputTokens = ollamaResponse.eval_count ?? 0; // stop_reason derived from tool_calls presence, NOT done_reason. // Ollama emits done_reason="stop" even when tool_calls are present // (ollama/ollama#12557) — naive mapping would falsely halt Claude Code's loop. return { id: `msg_${Date.now()}`, type: "message", role: "assistant", model: requestedModel, content: contentItems, stop_reason: toolCalls.length > 0 ? "tool_use" : ollamaResponse.done ? "end_turn" : "max_tokens", stop_sequence: null, usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, }; } function toAnthropicResponse(openai, requestedModel, wantsThinking) { const choice = openai?.choices?.[0]; const message = choice?.message ?? {}; const usage = openai?.usage ?? {}; const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; const contentItems = []; // Pass through real reasoning_content as a thinking block const reasoningContent = typeof message.reasoning_content === "string" ? message.reasoning_content : ""; if (reasoningContent && wantsThinking) { contentItems.push({ type: "thinking", thinking: reasoningContent }); } else if (wantsThinking) { contentItems.push({ type: "thinking", thinking: "Reasoning not available from the backing model.", }); } if (toolCalls.length) { for (const call of toolCalls) { let input = {}; try { input = call.function?.arguments ? JSON.parse(call.function.arguments) : {}; } catch { input = {}; } contentItems.push({ type: "tool_use", id: call.id ?? `tool_${Date.now()}`, name: call.function?.name ?? "function", input, }); } } const textContent = message.content; if (typeof textContent === "string" && textContent.trim()) { contentItems.push({ type: "text", text: textContent }); } else if (Array.isArray(textContent)) { for (const part of textContent) { if (typeof part === "string") { contentItems.push({ type: "text", text: part }); } else if (part?.type === "text" && typeof part.text === "string") { contentItems.push({ type: "text", text: part.text }); } } } if (contentItems.length === 0) { contentItems.push({ type: "text", text: "" }); } return { id: openai.id ?? `msg_${Date.now()}`, type: "message", role: "assistant", // Prefer the model the provider actually served with; fall back to the // requested model only when the provider omits it. Mirrors the direct // (non-tool) path at `databricksResponse.json.model || requestedModel`, so // tool-call responses no longer report a stale/aliased client-request model. model: (typeof openai?.model === "string" && openai.model.trim()) ? openai.model : requestedModel, content: contentItems, stop_reason: choice?.finish_reason === "stop" ? "end_turn" : choice?.finish_reason === "length" ? "max_tokens" : choice?.finish_reason === "tool_calls" ? "tool_use" : choice?.finish_reason ?? "end_turn", stop_sequence: null, usage: { // Accept both OpenAI (prompt_tokens/completion_tokens) and // already-Anthropic (input_tokens/output_tokens) usage shapes so token // counts survive regardless of which provider/converter produced them. input_tokens: usage.prompt_tokens ?? usage.input_tokens ?? 0, output_tokens: usage.completion_tokens ?? usage.output_tokens ?? 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, }; } function sanitizePayload(payload) { const { clonePayloadSmart } = require("../utils/payload"); // Honor a forceProvider marker (set by the OAuth tier-routing path) so the // tool-format / system-flatten / strip-thinking branches downstream match // the actual destination provider, not the static MODEL_PROVIDER default. // Without this, a TIER_SIMPLE=ollama:... user gets the "databricks" branch // running normaliseTools — which wraps tools in OpenAI {type:"function",...} // shape, leaving Ollama with tools named "function" and a model that // (correctly) reports no real tools available. const providerType = payload?._forceProvider || config.modelProvider?.type || "databricks"; const willFlatten = providerType !== "azure-anthropic"; const clean = clonePayloadSmart(payload ?? {}, { willFlatten }); const requestedModel = (typeof payload?.model === "string" && payload.model.trim().length > 0 ? payload.model.trim() : null) ?? config.modelProvider?.defaultModel ?? "databricks-claude-sonnet-4-5"; clean.model = requestedModel; if (!clean.max_tokens) { clean.max_tokens = 16384; } const flattenContent = willFlatten; clean.messages = normaliseMessages(clean, { flattenContent }).filter((msg) => { const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; if (!msg?.content) { return hasToolCalls; } if (typeof msg.content === "string") { return hasToolCalls || msg.content.trim().length > 0; } if (Array.isArray(msg.content)) { return hasToolCalls || msg.content.length > 0; } if (typeof msg.content === "object" && msg.content !== null) { return hasToolCalls || Object.keys(msg.content).length > 0; } return hasToolCalls; }); if (providerType === "azure-anthropic") { const cleanedMessages = []; for (const message of clean.messages) { if (isPlaceholderToolResultMessage(message)) { let toolUseId = null; if (Array.isArray(message.content)) { for (const block of message.content) { if (block?.type === "tool_result" && block.tool_use_id) { toolUseId = block.tool_use_id; break; } } } removeMatchingAssistantToolUse(cleanedMessages, toolUseId); continue; } const stripped = stripPlaceholderWebSearchContent(message); if (stripped) { cleanedMessages.push(stripped); } } clean.messages = cleanedMessages; const systemChunks = []; clean.messages = clean.messages.filter((msg) => { if (msg?.role === "tool") { return false; } if (msg?.role === "system") { if (typeof msg.content === "string" && msg.content.trim().length > 0) { systemChunks.push(msg.content.trim()); } return false; } return true; }); if (systemChunks.length > 0) { clean.system = systemChunks.join("\n\n"); } else if (typeof clean.system === "string" && clean.system.trim().length > 0) { clean.system = clean.system.trim(); } else { delete clean.system; } const azureDefaultModel = config.modelProvider?.defaultModel && config.modelProvider.defaultModel.trim().length > 0 ? config.modelProvider.defaultModel.trim() : "claude-opus-4-5"; clean.model = azureDefaultModel; } else if (providerType === "ollama") { // Ollama format conversion // Check if model supports tools const { modelNameSupportsTools } = require("../clients/ollama-utils"); const modelSupportsTools = modelNameSupportsTools(config.ollama?.model); if (!modelSupportsTools) { // Filter out tool_result content blocks for models without tool support clean.messages = clean.messages .map((msg) => { if (Array.isArray(msg.content)) { // Filter out tool_use and tool_result blocks const textBlocks = msg.content.filter( (block) => block.type === "text" && block.text ); if (textBlocks.length > 0) { // Convert to simple string format for Ollama return { role: msg.role, content: textBlocks.map((b) => b.text).join("\n"), }; } return null; } return msg; }) .filter(Boolean); } else { // Keep tool blocks for tool-capable models // But flatten content to simple string for better compatibility clean.messages = clean.messages.map((msg) => { if (Array.isArray(msg.content)) { const textBlocks = msg.content.filter( (block) => block.type === "text" && block.text ); if (textBlocks.length > 0) { return { role: msg.role, content: textBlocks.map((b) => b.text).join("\n"), }; } } return msg; }); } // Keep system prompt separate for Ollama (same as other providers) // Let invokeOllama() handle body.system properly } else { delete clean.system; } DROP_KEYS.forEach((key) => delete clean[key]); // Conditionally keep or strip the `thinking` parameter based on provider const { getThinkingBehavior } = require("../clients/provider-capabilities"); const thinkingBehavior = getThinkingBehavior(providerType, clean.model); if (clean.thinking && thinkingBehavior !== "native") { delete clean.thinking; } if (Array.isArray(clean.tools) && clean.tools.length === 0) { delete clean.tools; } else if (providerType === "databricks") { const tools = normaliseTools(clean.tools); if (tools) clean.tools = tools; else delete clean.tools; } else if (providerType === "azure-anthropic") { const tools = sanitiseAzureTools(clean.tools); clean.tools = tools && tools.length > 0 ? tools : DEFAULT_AZURE_TOOLS.map((tool) => ({ name: tool.name, input_schema: JSON.parse(JSON.stringify(tool.input_schema)), })); delete clean.tool_choice; } else if (providerType === "ollama") { // Always pass tools through to Ollama in Anthropic format when they exist. // Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if // the underlying model doesn't actually emit tool_use blocks, the model // simply responds conversationally — which is the correct fallback. Don't // strip the tools array based on heuristics about user intent or a // hardcoded "model supports tools" check, both of which produce // tool-blind responses ("I don't have file system access") when the // client (Claude Code) is clearly in an agentic session. if (Array.isArray(clean.tools) && clean.tools.length > 0) { clean.tools = ensureAnthropicToolFormat(clean.tools); } } else if (providerType === "openrouter" || providerType === "edenai") { // OpenRouter / Eden AI (OpenAI-compatible) support tools - keep them as-is. // Tools are already in Anthropic format and will be converted by openrouter-utils if (!Array.isArray(clean.tools) || clean.tools.length === 0) { delete clean.tools; } } else if (providerType === "zai") { // Z.AI (Zhipu) supports tools - keep them in Anthropic format // They will be converted to OpenAI format in invokeZai if (!Array.isArray(clean.tools) || clean.tools.length === 0) { delete clean.tools; } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } } else if (providerType === "vertex") { // Vertex AI supports tools - keep them in Anthropic format if (!Array.isArray(clean.tools) || clean.tools.length === 0) { delete clean.tools; } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } } else if (providerType === "moonshot") { // Moonshot supports tools - keep them in Anthropic format // They will be converted to OpenAI format in invokeMoonshot if (!Array.isArray(clean.tools) || clean.tools.length === 0) { delete clean.tools; } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } } else if (providerType === "baidu") { // Baidu Qianfan supports tools - keep them in Anthropic format // They will be converted to OpenAI format in invokeBaidu if (!Array.isArray(clean.tools) || clean.tools.length === 0) { delete clean.tools; } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } } else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") { // Azure OpenAI / OpenAI-compatible providers support tools — keep Anthropic format; the // client converts to Chat Completions / Responses format. Without this // branch the unknown-provider catch-all below deletes the tools. if (!Array.isArray(clean.tools) || clean.tools.length === 0) { delete clean.tools; } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } } else if (Array.isArray(clean.tools)) { // Unknown provider - remove tools for safety delete clean.tools; } if (providerType === "databricks") { const toolChoice = normaliseToolChoice(clean.tool_choice); if (toolChoice !== undefined) clean.tool_choice = toolChoice; else delete clean.tool_choice; } else if (providerType === "ollama") { // Tool choice handling const { modelNameSupportsTools } = require("../clients/ollama-utils"); const modelSupportsTools = modelNameSupportsTools(config.ollama?.model); if (!modelSupportsTools) { delete clean.tool_choice; } // For tool-capable models, Ollama doesn't support tool_choice, so remove it delete clean.tool_choice; } else if (clean.tool_choice === undefined || clean.tool_choice === null) { delete clean.tool_choice; } // The client owns tool execution — its tools always pass through intact. // Stripping any would make the model emit calls for tools we removed; they // then get dropped as "hallucinated" and the session makes no progress. // Default false: the buffered path parses tool calls from complete JSON // and Lynkr synthesises SSE back to the client. runAgentLoop flips this to // true per-step when the Phase-2b stream transform is active. clean.stream = false; if ( config.modelProvider?.type === "azure-anthropic" && logger && typeof logger.debug === "function" ) { try { logger.debug( { model: clean.model, temperature: clean.temperature ?? null, max_tokens: clean.max_tokens ?? null, tool_count: Array.isArray(clean.tools) ? clean.tools.length : 0, has_tool_choice: clean.tool_choice !== undefined, messages: clean.messages, }, "Azure Anthropic sanitized payload", ); logger.debug( { payload: JSON.parse(JSON.stringify(clean)), }, "Azure Anthropic request payload", ); } catch (err) { logger.debug({ err }, "Failed logging Azure Anthropic payload"); } } // Optional TOON conversion for large JSON message payloads (prompt context only). // Run this BEFORE message coalescing to preserve parseable JSON boundaries. // GCF takes precedence when enabled; otherwise TOON. Both are opt-in and mutually exclusive. if (config.gcf && config.gcf.enabled) { applyGcfCompression(clean, config.gcf, { logger }); } else { applyToonCompression(clean, config.toon, { logger }); } // Handle consecutive messages with the same role (causes llama.cpp 400 error) // Strategy: Merge consecutive same-role messages, but NEVER merge messages // that contain tool_use or tool_result blocks — they must stay intact for // the provider's tool-call protocol. if (Array.isArray(clean.messages) && clean.messages.length > 0) { const merged = []; const messages = clean.messages; const hasToolContent = (msg) => { if (Array.isArray(msg?.content)) { return msg.content.some(b => b && (b.type === 'tool_use' || b.type === 'tool_result')); } return Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; }; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; const prev = merged.length > 0 ? merged[merged.length - 1] : null; if (prev && msg.role === prev.role && !hasToolContent(msg) && !hasToolContent(prev)) { const prevContent = typeof prev.content === 'string' ? prev.content : JSON.stringify(prev.content); const currContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); prev.content = prevContent + '\n\n' + currContent; } else { merged.push({ ...msg }); } } if (merged.length !== clean.messages.length) { logger.debug({ originalCount: clean.messages.length, mergedCount: merged.length, }, 'Merged consecutive messages with same role'); } clean.messages = merged; } logger.debug({ providerType: config.modelProvider?.type ?? "databricks", messageCount: clean.messages?.length ?? 0, toolCount: clean.tools?.length ?? 0 }, 'After sanitizePayload'); // === Suggestion mode: tag request and override model if configured === const { isSuggestionMode: isSuggestion } = detectSuggestionMode(clean.messages); clean._requestMode = isSuggestion ? "suggestion" : "main"; const smConfig = config.modelProvider?.suggestionModeModel ?? "default"; if (isSuggestion && smConfig.toLowerCase() !== "default" && smConfig.toLowerCase() !== "none") { clean.model = smConfig; clean._suggestionModeModel = smConfig; } return clean; } // maxSteps default is 2, not 1: the loop no longer executes tools (the client // owns them), but hallucination recovery still needs one re-prompt iteration. const DEFAULT_LOOP_OPTIONS = { maxSteps: config.policy.maxStepsPerTurn ?? 2, maxDurationMs: 120000, maxToolCallsPerRequest: config.policy.maxToolCallsPerRequest ?? 20, // Prevent runaway tool calling }; function resolveLoopOptions(options = {}) { const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0 ? options.maxSteps : DEFAULT_LOOP_OPTIONS.maxSteps; const maxDurationMs = Number.isInteger(options.maxDurationMs) && options.maxDurationMs > 0 ? options.maxDurationMs : DEFAULT_LOOP_OPTIONS.maxDurationMs; const maxToolCallsPerRequest = Number.isInteger(options.maxToolCallsPerRequest) && options.maxToolCallsPerRequest > 0 ? options.maxToolCallsPerRequest : DEFAULT_LOOP_OPTIONS.maxToolCallsPerRequest; return { ...DEFAULT_LOOP_OPTIONS, maxSteps, maxDurationMs, maxToolCallsPerRequest, }; } /** * Create a signature for a tool call to detect identical repeated calls * @param {Object} toolCall - The tool call object * @returns {string} - A hash signature of the tool name and parameters */ function getToolCallSignature(toolCall) { const crypto = require('crypto'); const name = toolCall.function?.name ?? toolCall.name ?? 'unknown'; const args = toolCall.function?.arguments ?? toolCall.input; // Parse arguments if they're a string let argsObj = args; if (typeof args === 'string') { try { argsObj = JSON.parse(args); } catch (err) { argsObj = args; // Use raw string if parse fails } } // Create a deterministic signature const signature = `${name}:${JSON.stringify(argsObj)}`; return crypto.createHash('sha256').update(signature).digest('hex').substring(0, 16); } function buildNonJsonResponse(databricksResponse) { return { status: databricksResponse.status, headers: { "Content-Type": databricksResponse.contentType ?? "text/plain", }, body: databricksResponse.text, terminationReason: "non_json_response", }; } /** * Visible routing badge for live-streamed responses, built from the actual * post-routing decision (tier fallbacks included). Matches the format of * openai-router's lynkrBadge and the Anthropic router's intent badge so * LYNKR_BADGE_PREFIX_RE strips all three identically from replayed history. */ function buildRoutingBadge(rd) { if (!rd || !rd.tier) return null; const score = typeof rd.score === "number" ? ` · score ${rd.score}` : ""; return `*[Lynkr] ${rd.tier} → ${rd.model || "—"} (${rd.provider || "—"})${score}*\n\n`; } function buildStreamingResponse(databricksResponse) { return { status: databricksResponse.status, headers: { "Content-Type": databricksResponse.contentType ?? "text/event-stream", }, stream: databricksResponse.stream, terminationReason: "streaming", }; } function buildErrorResponse(databricksResponse) { return { status: databricksResponse.status, body: databricksResponse.json, terminationReason: "api_error", }; } async function runAgentLoop({ cleanPayload, requestedModel, wantsThinking, session, options, cacheKey, providerType, headers, }) { logger.debug({ providerType, messageCount: cleanPayload.messages?.length }, 'runAgentLoop entered'); const { createTimer } = require("../utils/perf-timer"); const agentTimer = createTimer("agentLoop"); const settings = resolveLoopOptions(options); // Initialize audit logger (no-op if disabled) const auditLogger = createAuditLogger(config.audit); const start = Date.now(); let steps = 0; while (steps < settings.maxSteps) { if (Date.now() - start > settings.maxDurationMs) { break; } // Check if system is shutting down (Ctrl+C or SIGTERM) if (getShuttingDown()) { logger.info( { sessionId: session?.id ?? null, steps, durationMs: Date.now() - start, }, "Agent loop interrupted - system shutting down", ); return { response: { status: 503, body: { error: { type: "service_unavailable", message: "Service is shutting down. Request was interrupted gracefully.", }, }, terminationReason: "shutdown", }, steps, durationMs: Date.now() - start, terminationReason: "shutdown", }; } steps += 1; logger.debug( { sessionId: session?.id ?? null, step: steps, maxSteps: settings.maxSteps, }, "Agent loop step", ); // Trim over-long loop conversations to prevent OOM, keeping the head, // THE CURRENT TASK (latest user message with real typed text), and the // recent tail — a head+tail-only trim discards the ask itself when the // session opened with a greeting, and the model answers the greeting. // Tunable: LYNKR_MAX_LOOP_MESSAGES (default 40; 0 disables trimming // entirely). Disabling trades flat per-frame cost/latency for full // context fidelity — on weak models expect coherence loss on very long // loops (their competence degrades before their context window fills), // and on paid models expect per-frame input cost to grow with session // length. The task-preservation fix makes the default cap safe. const MAX_LOOP_MESSAGES = Number.isFinite(Number(process.env.LYNKR_MAX_LOOP_MESSAGES)) ? Number(process.env.LYNKR_MAX_LOOP_MESSAGES) : 40; if (MAX_LOOP_MESSAGES > 0 && cleanPayload.messages && cleanPayload.messages.length > MAX_LOOP_MESSAGES) { cleanPayload.messages = trimLoopMessages(cleanPayload.messages, MAX_LOOP_MESSAGES); } // Debug: Log payload before sending to Azure if (providerType === "azure-anthropic") { logger.debug( { sessionId: session?.id ?? null, messageCount: cleanPayload.messages?.length ?? 0, messageRoles: cleanPayload.messages?.map(m => m.role) ?? [], lastMessage: cleanPayload.messages?.[cleanPayload.messages.length - 1], }, "Azure Anthropic request payload structure", ); } if (steps === 1 && agentTimer) agentTimer.mark("preCompression"); // === CONVERSATION DISTILLATION (TencentDB-inspired L0-L3 pipeline) === // Long conversations collapse older turns into one distilled block // (persona + scenario summary) before history compression runs. if (steps === 1 && config.memory?.enabled !== false && config.memory?.distillation?.enabled !== false) { try { const distiller = require('../memory/distiller'); if (distiller.needsDistillation(cleanPayload.messages)) { const result = distiller.distillMessages(cleanPayload.messages, { sessionId: session?.id, }); if (result.applied) { cleanPayload.messages = result.messages; logger.debug({ sessionId: session?.id ?? null, ...result.stats, }, '[distiller] Conversation distillation applied'); } } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'Distillation failed, continuing with full history'); } } if (steps === 1 && config.historyCompression?.enabled !== false) { try { if (historyCompression.needsCompression(cleanPayload.messages)) { const originalMessages = cleanPayload.messages; cleanPayload.messages = historyCompression.compressHistory(originalMessages, { keepRecentTurns: config.historyCompression?.keepRecentTurns ?? 10, summarizeOlder: config.historyCompression?.summarizeOlder ?? true, enabled: true }); if (cleanPayload.messages !== originalMessages) { const stats = historyCompression.calculateCompressionStats(originalMessages, cleanPayload.messages); logger.debug({ sessionId: session?.id ?? null, ...stats }, 'History compression applied'); } } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'History compression failed, continuing with full history'); } } // === MEMORY RETRIEVAL (Titans-inspired long-term memory) === if (config.memory?.enabled !== false && steps === 1) { try { const memoryRetriever = require('../memory/retriever'); // Get last user message for query const lastUserMessage = cleanPayload.messages ?.filter(m => m.role === 'user') ?.pop(); if (lastUserMessage) { const query = memoryRetriever.extractQueryFromMessage(lastUserMessage); if (query) { const relevantMemories = memoryRetriever.retrieveRelevantMemories(query, { limit: config.memory.retrievalLimit ?? 5, sessionId: session?.id, includeGlobal: config.memory.includeGlobalMemories !== false, }); if (relevantMemories.length > 0) { logger.debug({ sessionId: session?.id ?? null, memoriesRetrieved: relevantMemories.length, }, 'Injecting long-term memories into context'); const injectedSystem = memoryRetriever.injectMemoriesIntoSystem( cleanPayload.system, relevantMemories, config.memory.injectionFormat ?? 'system', cleanPayload.messages // Pass recent messages for deduplication ); if (typeof injectedSystem === 'string') { cleanPayload.system = injectedSystem; } else if (injectedSystem.system) { cleanPayload.system = injectedSystem.system; } } } } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'Memory retrieval failed, continuing without memories'); } } logger.debug({ sessionId: session?.id ?? null, messageCount: cleanPayload.messages?.length ?? 0, toolCount: cleanPayload.tools?.length ?? 0 }, 'After memory injection'); if (steps === 1 && (config.systemPrompt?.mode === 'dynamic' || config.systemPrompt?.toolDescriptions === 'minimal')) { try { // Compress tool descriptions if configured if (cleanPayload.tools && cleanPayload.tools.length > 0 && config.systemPrompt?.toolDescriptions === 'minimal') { const originalTools = cleanPayload.tools; cleanPayload.tools = systemPrompt.compressToolDescriptions(originalTools, 'minimal'); const originalSize = JSON.stringify(originalTools).length; const compressedSize = JSON.stringify(cleanPayload.tools).length; const saved = originalSize - compressedSize; if (saved > 100) { logger.debug({ sessionId: session?.id ?? null, toolCount: cleanPayload.tools.length, originalChars: originalSize, compressedChars: compressedSize, saved, percentage: ((saved / originalSize) * 100).toFixed(1) }, 'Tool descriptions compressed'); } } // Optimize system prompt if configured if (cleanPayload.system && config.systemPrompt?.mode === 'dynamic') { const originalSystem = cleanPayload.system; const optimizedSystem = systemPrompt.optimizeSystemPrompt( originalSystem, { tools: cleanPayload.tools, messages: cleanPayload.messages }, 'dynamic' ); if (optimizedSystem !== originalSystem) { const savings = systemPrompt.calculateSavings(originalSystem, optimizedSystem); cleanPayload.system = optimizedSystem; if (savings.tokensSaved > 50) { logger.debug({ sessionId: session?.id ?? null, ...savings }, 'System prompt optimized'); } } } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'System prompt optimization failed, continuing with original'); } } // Inject agent delegation instructions when Task tool is available (for all models) if (steps === 1 && config.agents?.enabled !== false) { try { const injectedSystem = systemPrompt.injectAgentInstructions( cleanPayload.system || '', cleanPayload.tools ); if (injectedSystem !== cleanPayload.system) { cleanPayload.system = injectedSystem; logger.debug({ sessionId: session?.id ?? null, hasTaskTool: true }, 'Agent delegation instructions injected into system prompt'); } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'Agent instructions injection failed, continuing without'); } } const hasRequestTools = Array.isArray(cleanPayload.tools) && cleanPayload.tools.length > 0; // Inject tool termination instructions for non-Claude models — only when tools // are actually in the request. Injecting when there are no tools confuses models // like MiniMax into hallucinating tool_use blocks spontaneously. if (steps === 1 && hasRequestTools && providerType !== 'databricks' && providerType !== 'azure-anthropic') { const toolTerminationInstruction = ` IMPORTANT TOOL USAGE RULES: - After receiving tool results, you MUST provide a text response summarizing the results for the user. - Do NOT call the same tool repeatedly with the same or similar parameters. - If a tool returns results, use those results to answer the user's question. - If a tool fails or returns unexpected results, explain this to the user instead of retrying. - Maximum 2-3 tool calls per user request. After that, provide your best answer based on available information. `; cleanPayload.system = (cleanPayload.system || '') + toolTerminationInstruction; logger.debug({ sessionId: session?.id ?? null }, 'Tool termination instructions injected for non-Claude model'); } // When no tools are in the request, explicitly forbid tool_use output for // Ollama models that have been trained on Claude Code data and tend to emit // tool_use blocks spontaneously (e.g. minimax-m2.5:cloud calling Write). if (steps === 1 && !hasRequestTools && providerType === 'ollama') { cleanPayload.system = (cleanPayload.system || '') + '\n\nCRITICAL: You have NO tools available. Do NOT generate tool_use, function_call, or code_execution blocks. Output ONLY text content directly.'; } // Compute model-aware token budget thresholds const registry = getModelRegistrySync(); const modelInfo = registry.getCost(requestedModel); const modelContextWindow = modelInfo?.context || config.tokenBudget?.max || 180000; const modelMax = Math.floor(modelContextWindow * 0.85); const effectiveMax = Math.min(modelMax, config.tokenBudget?.max || 180000); const effectiveWarning = Math.floor(effectiveMax * 0.65); logger.debug({ sessionId: session?.id ?? null, requestedModel, modelContextWindow, effectiveWarning, effectiveMax, source: modelInfo?.source || 'default', }, 'Model-aware token budget computed'); if (steps === 1 && config.tokenBudget?.enforcement !== false) { try { const budgetCheck = tokenBudget.checkBudget(cleanPayload, effectiveWarning, effectiveMax); if (budgetCheck.atWarning) { logger.warn({ sessionId: session?.id ?? null, totalTokens: budgetCheck.totalTokens, warningThreshold: budgetCheck.warningThreshold, maxThreshold: budgetCheck.maxThreshold, overMax: budgetCheck.overMax }, 'Approaching or exceeding token budget'); if (budgetCheck.overMax) { // Apply adaptive compression to fit within budget const enforcement = tokenBudget.enforceBudget(cleanPayload, { warningThreshold: effectiveWarning, maxThreshold: effectiveMax, enforcement: true }); if (enforcement.compressed) { cleanPayload = enforcement.payload; logger.info({ sessionId: session?.id ?? null, strategy: enforcement.strategy, initialTokens: enforcement.stats.initialTokens, finalTokens: enforcement.stats.finalTokens, saved: enforcement.stats.saved, percentage: enforcement.stats.percentage, nowWithinBudget: !enforcement.finalBudget.overMax }, 'Token budget enforcement applied'); } } } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'Token budget enforcement failed, continuing without enforcement'); } } // Track estimated token usage before model call const estimatedTokens = config.tokenTracking?.enabled !== false ? tokens.countPayloadTokens(cleanPayload) : null; if (estimatedTokens && config.tokenTracking?.enabled !== false) { logger.debug({ sessionId: session?.id ?? null, estimated: estimatedTokens, model: cleanPayload.model }, 'Estimated token usage before model call'); } // Apply Headroom compression if enabled. // // Headroom is configured for a single provider (HEADROOM_PROVIDER, default // 'anthropic'). Its Tool Crusher rewrites tool results compactly, Cache // Aligner restructures messages to maximize that provider's prompt-cache // hit pattern, and Smart Crusher does semantic compression — all tuned for // Anthropic. Sending the compressed output to a different model family // (Ollama, OpenAI, etc.) yields output the receiver reads as "garbled tool // result" and the agent loop stalls. // // Gate Headroom on providers matching HEADROOM_PROVIDER. By default that's // Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the // analogous gate. const headroomProviderMap = { 'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter', 'edenai']), 'openai': new Set(['azure-openai', 'openai', 'openrouter', 'edenai']), 'google': new Set(['vertex', 'openrouter', 'edenai']), }; const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic'; const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set(); // Under tier routing the static providerType is just the MODEL_PROVIDER // default (usually "databricks") — the real provider is chosen per-request // later in invokeModel, AFTER this compression point, so gating on it // silently disabled Headroom for every tier-routed setup. Gate on the // configured tier fleet instead: compress only when every routable tier // provider is format-safe, since any request may land on any tier. let headroomCompatible = headroomSafeProviders.has(providerType); if (!headroomCompatible) { try { const { getModelTierSelector } = require('../routing/model-tiers'); const sel = getModelTierSelector(); const tierProviders = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'] .map((t) => { try { return sel.selectModel(t)?.provider; } catch { return null; } }) .filter(Boolean) .map((p) => (p === 'z-ai' ? 'zai' : p)); headroomCompatible = tierProviders.length > 0 && tierProviders.every((p) => headroomSafeProviders.has(p)); } catch { /* tier routing not configured — keep the static gate result */ } } if (isHeadroomEnabled() && headroomCompatible && cleanPayload.messages && cleanPayload.messages.length > 0) { try { const compressionResult = await headroomCompress( cleanPayload.messages, cleanPayload.tools || [], { mode: config.headroom?.mode, queryContext: (() => { const last = cleanPayload.messages[cleanPayload.messages.length - 1]?.content; if (typeof last === 'string') return last; if (Array.isArray(last)) { return last .map(b => (b?.type === 'text' ? b.text : b?.type === 'tool_result' ? String(b.content ?? '') : '')) .filter(Boolean) .join('\n') || null; } return null; })(), model: requestedModel, modelLimit: modelContextWindow, tokenBudget: effectiveMax, } ); if (compressionResult.compressed) { cleanPayload.messages = compressionResult.messages; if (compressionResult.tools) { cleanPayload.tools = compressionResult.tools; } } logger.debug({ sessionId: session?.id ?? null, outcome: compressionResult.compressed ? 'applied' : 'skipped', tokensBefore: compressionResult.stats?.tokens_before, tokensAfter: compressionResult.stats?.tokens_after, savingsPercent: compressionResult.stats?.savings_percent, reason: compressionResult.stats?.reason || compressionResult.stats?.transforms_applied?.join(', ') || 'none', }, 'Headroom compression'); } catch (headroomErr) { logger.warn({ err: headroomErr, sessionId: session?.id ?? null }, 'Headroom compression failed, using original messages'); } } else if (isHeadroomEnabled() && !headroomCompatible) { logger.debug({ providerType, headroomProvider, reason: 'provider_mismatch', }, 'Headroom skipped — provider does not match HEADROOM_PROVIDER family'); } // Generate correlation ID for request/response pairing const correlationId = `req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`; if (auditLogger.enabled) { auditLogger.logLlmRequest({ correlationId, sessionId: session?.id ?? null, provider: providerType, model: cleanPayload.model, stream: cleanPayload.stream ?? false, destinationUrl: getDestinationUrl(providerType), userMessages: cleanPayload.messages, systemPrompt: cleanPayload.system, tools: cleanPayload.tools, maxTokens: cleanPayload.max_tokens, }); } // Thread workspace for code-graph integration (auto-detected or from header) if (headers?.["x-lynkr-workspace"]) { cleanPayload._workspace = headers["x-lynkr-workspace"]; } // Phase 6.3 — thread deadline for latency-aware routing. if (headers?.["lynkr-deadline-ms"]) { const dl = parseInt(headers["lynkr-deadline-ms"], 10); if (!isNaN(dl) && dl > 0) cleanPayload._deadlineMs = dl; } // Phase 6.1 — thread tenant policy for per-tenant routing overrides. if (options?.tenantPolicy) { cleanPayload._tenantPolicy = options.tenantPolicy; } // Thread session id for provider affinity — keeps a tool-bearing // conversation on one provider so tool_call_id linkage doesn't break. if (session?.id) { cleanPayload._sessionId = session.id; } // RTK-inspired tool result compression: compress large tool_results // before they reach the model (saves 60-90% on test/git/lint output) if (config.toolResultCompression?.enabled !== false) { const { compressToolResults } = require("../context/tool-result-compressor"); // Fixed threshold: compression must be deterministic per message — the // routed tier flaps between turns, and re-compressing history differently // breaks provider prompt-cache prefixes. COMPLEX (>2000 chars) compresses // only bulky outputs: with prompt caching live, resending history is // cheap, so lighter lossiness beats aggressive compression; still bounds // context growth enough to stay clear of the token-budget compressor. compressToolResults(cleanPayload.messages, { tier: "COMPLEX" }); } // MCP-aware tool dedup: drop built-in tools superseded by present MCP tools // (e.g. WebSearch/WebFetch when Exa/Tavily MCP is available). Always on. const { applyToolDedup } = require("../context/tool-dedup"); applyToolDedup(cleanPayload); // Caveman terse-output injection (opt-in): nudge the model toward shorter // responses to reduce output tokens. // // Default safe-set is the Claude-family + capable instruction-following // models. Operators can override via LYNKR_CAVEMAN_SAFE_PROVIDERS=a,b,c. // (Some smaller / older models read "respond like a terse caveman" too // literally and produce broken telegraphic English — keep them out of the // set if you see that degradation.) const DEFAULT_CAVEMAN_SAFE = [ 'azure-anthropic', 'bedrock', 'vertex', 'openrouter', 'edenai', 'ollama', 'openai', 'azure-openai', 'moonshot', 'zai', 'databricks', ]; const cavemanSafeEnv = process.env.LYNKR_CAVEMAN_SAFE_PROVIDERS; const CAVEMAN_SAFE_PROVIDERS = new Set( cavemanSafeEnv ? cavemanSafeEnv.split(',').map(s => s.trim()).filter(Boolean) : DEFAULT_CAVEMAN_SAFE ); if (config.caveman?.enabled === true && CAVEMAN_SAFE_PROVIDERS.has(providerType)) { const { injectCaveman } = require("../context/caveman"); cleanPayload.system = injectCaveman(cleanPayload.system); } else if (config.caveman?.enabled === true) { logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)'); } // Phase 2b — cross-format streaming (opt-in via LYNKR_STREAM_TRANSFORM). // When the client wants a stream and the upstream speaks OpenAI SSE, // request a streamed upstream response and reshape it in flight instead of // buffering. Only on the first step: a hallucination-recovery re-prompt // needs a parsed, buffered response. const sseTransform = require("./sse-transformer"); const _streamProvider = cleanPayload._forceProvider || providerType; const _wantsTransformStream = steps === 1 && sseTransform.shouldTransform(options?.clientWantsStream, _streamProvider); cleanPayload.stream = _wantsTransformStream; if (agentTimer) agentTimer.mark("preInvokeModel"); let databricksResponse; // Honor a body-level forceProvider marker (set by the OAuth tier-routing // path in the router) so the orchestrator's internal tier router can't // re-pick a different provider mid-flight. const invokeOpts = { headers }; if (cleanPayload._forceProvider) { invokeOpts.forceProvider = cleanPayload._forceProvider; delete cleanPayload._forceProvider; } try { databricksResponse = await invokeModel(cleanPayload, invokeOpts); if (agentTimer) agentTimer.mark("invokeModel"); } catch (modelError) { const isConnectionError = modelError.cause?.code === 'ECONNREFUSED' || modelError.message?.includes('fetch failed') || modelError.code === 'ECONNREFUSED'; if (isConnectionError) { logger.error(`Provider ${providerType} is unreachable (connection refused). Is it running?`); return { response: { status: 503, body: { error: { type: "provider_unreachable", message: `Provider ${providerType} is unreachable. Is the service running?`, }, }, terminationReason: "provider_unreachable", }, steps, durationMs: Date.now() - start, terminationReason: "provider_unreachable", }; } throw modelError; } const actualUsage = databricksResponse.ok && config.tokenTracking?.enabled !== false ? tokens.extractUsageFromResponse(databricksResponse.json) : null; if (estimatedTokens && actualUsage && config.tokenTracking?.enabled !== false) { tokens.logTokenUsage('model_invocation', estimatedTokens, actualUsage); // Record in session metadata if (session) { tokens.recordTokenUsage(session, steps, estimatedTokens, actualUsage, cleanPayload.model); } } // Cache-aware routing (Phase 1): persist the session's warm-prefix state // from the response's cache counters so the router can price a mid-session // model switch against the live cache clock. Best-effort — never blocks // the response path. if (session?.id && actualUsage) { try { // Prefer the ROUTED provider/model over the request-level default: // the tier router inside invokeModel may have diverged from // providerType, and the cache lives with whoever actually served // (verified live: databricks default label on ollama-served turns). const served = databricksResponse.routingDecision || {}; sessionAffinity.recordCacheUsage(session.id, { provider: served.provider || providerType, model: served.model || cleanPayload.model, cacheReadTokens: actualUsage.cacheReadTokens, cacheCreationTokens: actualUsage.cacheCreationTokens, }); } catch (err) { logger.debug({ err: err.message }, "[Orchestrator] cache-state update failed"); } } if (auditLogger.enabled) { const latencyMs = Date.now() - start; if (databricksResponse.stream) { auditLogger.logLlmResponse({ correlationId, sessionId: session?.id ?? null, provider: providerType, model: cleanPayload.model, stream: true, destinationUrl: getDestinationUrl(providerType), status: databricksResponse.status, latencyMs, streamingNote: 'Content streamed directly to client, not captured in audit log', }); } else if (databricksResponse.ok && databricksResponse.json) { const message = databricksResponse.json; const assistantMessage = message.content ?? message.choices?.[0]?.message; auditLogger.logLlmResponse({ correlationId, sessionId: session?.id ?? null, provider: providerType, model: cleanPayload.model, stream: false, destinationUrl: getDestinationUrl(providerType), assistantMessage, stopReason: message.stop_reason ?? message.choices?.[0]?.finish_reason ?? null, requestTokens: actualUsage?.input_tokens ?? actualUsage?.prompt_tokens ?? null, responseTokens: actualUsage?.output_tokens ?? actualUsage?.completion_tokens ?? null, latencyMs, status: databricksResponse.status, }); } else { auditLogger.logLlmResponse({ correlationId, sessionId: session?.id ?? null, provider: providerType, model: cleanPayload.model, stream: false, destinationUrl: getDestinationUrl(providerType), status: databricksResponse.status, latencyMs, error: databricksResponse.text ?? databricksResponse.json ?? 'Unknown error', }); } } // Handle streaming responses (pass through without buffering). An // ok:false "stream" is a provider error whose body was already consumed // for logging — let it fall through to the error branches below instead // of synthesizing an empty completion from a spent stream. if (databricksResponse.stream && databricksResponse.ok !== false) { logger.debug( { sessionId: session?.id ?? null, status: databricksResponse.status, transform: _wantsTransformStream, }, "Streaming response received, passing through" ); // Phase 2b: reshape OpenAI SSE into Anthropic SSE in flight. All // telemetry moves to the onClose finalizer — tool names/arg sizes are // accumulated DURING the stream, usage arrives with the final chunks. if (_wantsTransformStream) { const streamStartedAt = Date.now(); const routingDecision = databricksResponse.routingDecision || {}; const transformed = sseTransform.openaiToAnthropicSSE(databricksResponse.stream, { model: requestedModel, // Badge precedence: an explicit streamBadgeText (Anthropic router's // pre-computed intent badge) wins; otherwise build one from the // ACTUAL post-routing decision so OpenAI-surface live streams // (opencode et al.) show which model served the response — // previously this path silently dropped the badge entirely. // History pollution is covered: stripLynkrBadges in invokeModel // removes resubmitted badges before anything reaches a provider. badgeText: config.routing?.visibleInteraction ? (options?.streamBadgeText || buildRoutingBadge(routingDecision)) : null, onClose: (stats) => { try { const telemetry = require("../routing/telemetry"); telemetry.record({ // request_id is NOT NULL in the telemetry schema — a null id // silently drops the whole row. request_id: options?.correlationId || crypto.randomUUID(), session_id: session?.id ?? null, timestamp: streamStartedAt, tier: routingDecision.tier ?? null, provider: (routingDecision.provider || _streamProvider) + "", model: routingDecision.model ?? cleanPayload.model ?? null, routing_method: "stream-transform", status_code: stats.stopReason === "stream_error" ? 599 : 200, latency_ms: Date.now() - streamStartedAt, input_tokens: stats.usage.input_tokens, output_tokens: stats.usage.output_tokens, message_count: cleanPayload.messages?.length ?? null, tool_count: Array.isArray(cleanPayload.tools) ? cleanPayload.tools.length : 0, tool_calls_made: stats.toolCalls.length, error_type: stats.stopReason === "stream_error" ? "stream_error" : null, was_fallback: false, }); logger.info({ provider: routingDecision.provider || _streamProvider, stopReason: stats.stopReason, finishReason: stats.finishReason ?? null, toolCalls: stats.toolCalls.map((t) => t.name), outputTokens: stats.usage.output_tokens, }, "[SSETransform] Stream closed"); } catch (err) { logger.debug({ err: err.message }, "[SSETransform] Telemetry finalizer failed (non-fatal)"); } }, }); return { response: { status: databricksResponse.status, headers: { "Content-Type": "text/event-stream" }, stream: transformed, terminationReason: "streaming", }, steps, durationMs: Date.now() - start, terminationReason: "streaming", }; } return { response: buildStreamingResponse(databricksResponse), steps, durationMs: Date.now() - start, terminationReason: "streaming", }; } if (!databricksResponse.json) { appendTurnToSession(session, { role: "assistant", type: "error", status: databricksResponse.status, content: databricksResponse.text ?? "", metadata: { termination: "non_json_response" }, }); const response = buildNonJsonResponse(databricksResponse); logger.warn( { sessionId: session?.id ?? null, status: response.status, termination: response.terminationReason, }, "Agent loop terminated without JSON", ); return { response, steps, durationMs: Date.now() - start, terminationReason: response.terminationReason, }; } if (!databricksResponse.ok) { appendTurnToSession(session, { role: "assistant", type: "error", status: databricksResponse.status, content: databricksResponse.json, metadata: { termination: "api_error" }, }); const response = buildErrorResponse(databricksResponse); logger.error( { sessionId: session?.id ?? null, status: response.status, }, "Agent loop encountered API error", ); return { response, steps, durationMs: Date.now() - start, terminationReason: response.terminationReason, }; } // Extract message and tool calls based on provider response format let message = {}; let toolCalls = []; // Detect Anthropic format: has 'content' array and 'stop_reason' at top level (no 'choices') // This handles azure-anthropic provider AND azure-openai Responses API (which we convert to Anthropic format) const isAnthropicFormat = providerType === "azure-anthropic" || (Array.isArray(databricksResponse.json?.content) && databricksResponse.json?.stop_reason !== undefined && !databricksResponse.json?.choices); if (isAnthropicFormat) { // Anthropic format: { content: [{ type: "tool_use", ... }], stop_reason: "tool_use" } message = { content: databricksResponse.json?.content ?? [], stop_reason: databricksResponse.json?.stop_reason, }; // Extract tool_use blocks from content array const contentArray = Array.isArray(databricksResponse.json?.content) ? databricksResponse.json.content : []; toolCalls = contentArray .filter(block => block?.type === "tool_use") .map(block => ({ id: block.id, function: { name: block.name, arguments: JSON.stringify(block.input ?? {}), }, // Keep original block for reference _anthropic_block: block, })); // Extract tool calls from text blocks that contain XML (some Ollama models) if (toolCalls.length === 0) { const { extractToolCallsFromText } = require("../clients/xml-tool-extractor"); for (const block of contentArray) { if (block?.type === "text" && block?.text) { const extracted = extractToolCallsFromText(block.text); if (extracted.toolCalls.length > 0) { toolCalls = extracted.toolCalls; block.text = extracted.cleanedText || ""; break; } } } } logger.debug( { sessionId: session?.id ?? null, contentBlocks: contentArray.length, toolCallsFound: toolCalls.length, stopReason: databricksResponse.json?.stop_reason, }, "Azure Anthropic response parsed", ); } else { // OpenAI/Databricks format: { choices: [{ message: { tool_calls: [...] } }] } const choice = databricksResponse.json?.choices?.[0]; message = choice?.message ?? {}; toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; // Extract tool calls embedded as XML/text in content (Minimax, Qwen, GLM, Llama, etc.) if (toolCalls.length === 0 && typeof message.content === "string" && message.content.trim()) { const { extractToolCallsFromText } = require("../clients/xml-tool-extractor"); const extracted = extractToolCallsFromText(message.content); if (extracted.toolCalls.length > 0) { toolCalls = extracted.toolCalls; message.tool_calls = toolCalls; message.content = extracted.cleanedText; } } } // Guard: drop hallucinated tool calls when no tools were sent to the model. // Some models (e.g. Llama 3.1) hallucinate tool_call blocks from conversation // history even when the request contained zero tool definitions. const toolsWereSent = Array.isArray(cleanPayload.tools) && cleanPayload.tools.length > 0; if (toolCalls.length > 0 && !toolsWereSent) { logger.warn({ sessionId: session?.id ?? null, step: steps, hallucinated: toolCalls.map(tc => tc.function?.name || tc.name), noToolInjection: !!cleanPayload._noToolInjection, }, "Dropped hallucinated tool calls (no tools were sent to model)"); toolCalls = []; // Check if there is any text content alongside the hallucinated tool calls. // If not, the response is effectively empty. Inject a redirect message so the // model outputs the artifact directly instead of looping tool-call attempts. const hasTextContent = isAnthropicFormat ? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0) : (typeof message.content === "string" && message.content.trim().length > 0); if (!hasTextContent && steps < settings.maxSteps) { logger.info({ sessionId: session?.id ?? null, step: steps, }, "Hallucinated tool calls with no text content — injecting redirect to force direct output"); // Push a phantom assistant turn (thinking only, no tool_use) then a user // redirect message so the model outputs the artifact directly. const redirectUser = { role: "user", content: "You don't have any tools available in this context. Please output the result directly as an block containing complete HTML. Do not attempt to call any tools.", }; cleanPayload.messages.push(redirectUser); steps++; continue; } } if (toolCalls.length > 0) { // Auto-resolve web_search/web_fetch server-side, but ONLY for clients // src/routing/client-profiles.js's detectClient() didn't recognize — // i.e. we have no signal the caller can fulfill these itself. Known // harnesses (Claude Code AND Claude Desktop — both present as // claude-cli/... since Desktop's gateway mode runs the same agent-sdk; // also Cursor, goose, Codex) already execute these client-side and // must keep doing so unchanged — this branch never fires for them. // Does NOT reintroduce general server-mode tool execution (removed // 2026-07-22, commit b32e988): only these two tool names, only for // unrecognized clients, and only when EVERY call in this batch is one // we can resolve (a mixed batch falls through to the normal // forward-to-client path below, untouched). const clientProfile = cleanPayload._clientProfile || null; if (!clientProfile) { const webSearchExec = require("../tools/web-search-exec"); if (webSearchExec.canAutoResolveAll(toolCalls)) { logger.info({ sessionId: session?.id ?? null, step: steps, tools: toolCalls.map((tc) => tc.function?.name ?? tc.name), }, "[web-search-exec] Auto-resolving web_search/web_fetch for unrecognized client"); await webSearchExec.autoResolve(toolCalls, cleanPayload.messages); steps++; continue; } } // Convert OpenAI/OpenRouter format to Anthropic format for session storage let sessionContent; if (providerType === "azure-anthropic") { // Azure Anthropic already returns content in Anthropic sessionContent = databricksResponse.json?.content ?? []; } else { // Convert OpenAI/OpenRouter format to Anthropic content blocks const contentBlocks = []; let toolCallIdx = 0; if (message.content && typeof message.content === 'string' && message.content.trim()) { contentBlocks.push({ type: "text", text: message.content }); } for (const toolCall of toolCalls) { const func = toolCall.function || {}; let input = {}; if (func.arguments) { try { input = typeof func.arguments === "string" ? JSON.parse(func.arguments) : func.arguments; } catch (err) { logger.warn({ error: err.message, arguments: func.arguments }, "Failed to parse tool arguments for session storage"); input = {}; } } contentBlocks.push({ type: "tool_use", id: toolCall.id || `toolu_${Date.now()}_${(toolCallIdx++).toString(36)}_${Math.random().toString(36).substr(2, 6)}`, name: func.name || toolCall.name || "unknown", input }); } sessionContent = contentBlocks; } appendTurnToSession(session, { role: "assistant", type: "tool_request", status: 200, content: sessionContent, metadata: { termination: "tool_use", toolCalls: toolCalls.map((call) => ({ id: call.id, name: call.function?.name ?? call.name, })), }, }); // The client owns every tool: forward the tool_use turn untouched and // end this request — the client executes the tools and sends the // results back as a fresh request. // // Do NOT record outbound tool calls here — the inbound recording on // the next request (when the client sends results back) is enough to // detect real loops. Recording both outbound + inbound for the same // call double-counts and triggers the dedup warning on the very first // normal tool round-trip. const anthropicResponse = { id: databricksResponse.json?.id || `msg_${Date.now()}`, type: "message", role: "assistant", content: sessionContent, model: databricksResponse.json?.model || cleanPayload.model, stop_reason: "tool_use", usage: databricksResponse.json?.usage || { input_tokens: 0, output_tokens: 0, }, }; return { response: { status: 200, body: anthropicResponse, terminationReason: "tool_use", }, steps, durationMs: Date.now() - start, terminationReason: "tool_use", }; } let anthropicPayload; // Use actualProvider from invokeModel for hybrid routing support const actualProvider = databricksResponse.actualProvider || providerType; if (actualProvider === "bedrock") { // Bedrock with Claude models returns native Anthropic format // Other models are already converted by bedrock-utils anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "azure-anthropic") { anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "ollama") { const ollamaJson = databricksResponse.json; // Detect response format: Anthropic API (v0.14.0+) has type:"message", // legacy /api/chat has message.role + message.content if (ollamaJson?.type === "message" && Array.isArray(ollamaJson?.content)) { // Anthropic-native response — passthrough anthropicPayload = ollamaJson; } else { // Legacy Ollama response — convert to Anthropic format anthropicPayload = ollamaToAnthropicResponse(ollamaJson, requestedModel); } if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "openrouter" || actualProvider === "edenai") { const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils"); // Validate OpenRouter response has choices array before conversion if (!databricksResponse.json?.choices?.length) { logger.warn({ json: databricksResponse.json, status: databricksResponse.status }, "OpenRouter response missing choices array"); appendTurnToSession(session, { role: "assistant", type: "error", status: databricksResponse.status, content: databricksResponse.json, metadata: { termination: "malformed_response" }, }); const response = buildErrorResponse(databricksResponse); return { response, steps, durationMs: Date.now() - start, terminationReason: response.terminationReason, }; } anthropicPayload = convertOpenRouterResponseToAnthropic( databricksResponse.json, requestedModel, ); anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } else if (actualProvider === "azure-openai") { const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils"); // Check if response is already in Anthropic format (Azure AI Foundry Responses API) const isAnthropicFormat = databricksResponse.json?.type === "message" && Array.isArray(databricksResponse.json?.content) && databricksResponse.json?.stop_reason !== undefined; if (isAnthropicFormat) { // Azure AI Foundry Responses API returns Anthropic format directly logger.info({ format: "anthropic", contentBlocks: databricksResponse.json.content?.length || 0, contentTypes: databricksResponse.json.content?.map(c => c.type) || [], stopReason: databricksResponse.json.stop_reason, hasToolUse: databricksResponse.json.content?.some(c => c.type === 'tool_use') }, "=== AZURE RESPONSES API (ANTHROPIC FORMAT) ==="); // Use response directly - it's already in Anthropic format anthropicPayload = { id: databricksResponse.json.id, type: "message", role: databricksResponse.json.role || "assistant", content: databricksResponse.json.content, model: databricksResponse.json.model || requestedModel, stop_reason: databricksResponse.json.stop_reason, stop_sequence: databricksResponse.json.stop_sequence || null, usage: databricksResponse.json.usage || { input_tokens: 0, output_tokens: 0 } }; anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } else if (!databricksResponse.json?.choices?.length) { // Not Anthropic format and no choices array - malformed response logger.warn({ json: databricksResponse.json, status: databricksResponse.status }, "Azure OpenAI response missing choices array and not in Anthropic format"); appendTurnToSession(session, { role: "assistant", type: "error", status: databricksResponse.status, content: databricksResponse.json, metadata: { termination: "malformed_response" }, }); const response = buildErrorResponse(databricksResponse); return { response, steps, durationMs: Date.now() - start, terminationReason: response.terminationReason, }; } else { // Standard OpenAI format with choices array logger.info({ format: "openai", hasChoices: !!databricksResponse.json?.choices, choiceCount: databricksResponse.json?.choices?.length || 0, firstChoice: databricksResponse.json?.choices?.[0], hasToolCalls: !!databricksResponse.json?.choices?.[0]?.message?.tool_calls, toolCallCount: databricksResponse.json?.choices?.[0]?.message?.tool_calls?.length || 0, finishReason: databricksResponse.json?.choices?.[0]?.finish_reason }, "=== AZURE OPENAI (STANDARD FORMAT) ==="); // Convert OpenAI format to Anthropic format (reuse OpenRouter utility) anthropicPayload = convertOpenRouterResponseToAnthropic( databricksResponse.json, requestedModel, ); logger.info({ contentBlocks: anthropicPayload.content?.length || 0, contentTypes: anthropicPayload.content?.map(c => c.type) || [], stopReason: anthropicPayload.stop_reason, hasToolUse: anthropicPayload.content?.some(c => c.type === 'tool_use') }, "=== CONVERTED ANTHROPIC RESPONSE ==="); anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "openai") { const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils"); // Validate OpenAI response has choices array before conversion if (!databricksResponse.json?.choices?.length) { logger.warn({ json: databricksResponse.json, status: databricksResponse.status }, "OpenAI response missing choices array"); appendTurnToSession(session, { role: "assistant", type: "error", status: databricksResponse.status, content: databricksResponse.json, metadata: { termination: "malformed_response" }, }); const response = buildErrorResponse(databricksResponse); return { response, steps, durationMs: Date.now() - start, terminationReason: response.terminationReason, }; } logger.info({ hasChoices: !!databricksResponse.json?.choices, choiceCount: databricksResponse.json?.choices?.length || 0, hasToolCalls: !!databricksResponse.json?.choices?.[0]?.message?.tool_calls, toolCallCount: databricksResponse.json?.choices?.[0]?.message?.tool_calls?.length || 0, finishReason: databricksResponse.json?.choices?.[0]?.finish_reason }, "=== OPENAI RAW RESPONSE ==="); // Convert OpenAI format to Anthropic format (reuse OpenRouter utility) anthropicPayload = convertOpenRouterResponseToAnthropic( databricksResponse.json, requestedModel, ); logger.info({ contentBlocks: anthropicPayload.content?.length || 0, contentTypes: anthropicPayload.content?.map(c => c.type) || [], stopReason: anthropicPayload.stop_reason, hasToolUse: anthropicPayload.content?.some(c => c.type === 'tool_use') }, "=== CONVERTED ANTHROPIC RESPONSE (OpenAI) ==="); anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } else if (actualProvider === "llamacpp") { const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils"); // Validate llama.cpp response has choices array before conversion if (!databricksResponse.json?.choices?.length) { logger.warn({ json: databricksResponse.json, status: databricksResponse.status }, "llama.cpp response missing choices array"); appendTurnToSession(session, { role: "assistant", type: "error", status: databricksResponse.status, content: databricksResponse.json, metadata: { termination: "malformed_response" }, }); const response = buildErrorResponse(databricksResponse); return { response, steps, durationMs: Date.now() - start, terminationReason: response.terminationReason, }; } logger.info({ hasChoices: !!databricksResponse.json?.choices, choiceCount: databricksResponse.json?.choices?.length || 0, hasToolCalls: !!databricksResponse.json?.choices?.[0]?.message?.tool_calls, toolCallCount: databricksResponse.json?.choices?.[0]?.message?.tool_calls?.length || 0, finishReason: databricksResponse.json?.choices?.[0]?.finish_reason }, "=== LLAMA.CPP RAW RESPONSE ==="); // Convert llama.cpp format to Anthropic format (reuse OpenRouter utility) anthropicPayload = convertOpenRouterResponseToAnthropic( databricksResponse.json, requestedModel, ); logger.info({ contentBlocks: anthropicPayload.content?.length || 0, contentTypes: anthropicPayload.content?.map(c => c.type) || [], stopReason: anthropicPayload.stop_reason, hasToolUse: anthropicPayload.content?.some(c => c.type === 'tool_use') }, "=== CONVERTED ANTHROPIC RESPONSE (llama.cpp) ==="); anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } else if (actualProvider === "zai") { // Z.AI responses are already converted to Anthropic format in invokeZai anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "vertex") { // Vertex AI responses are already in Anthropic format anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "moonshot") { // Moonshot responses are already converted to Anthropic format in invokeMoonshot anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "baidu") { // Baidu Qianfan responses are already converted to Anthropic format in invokeBaidu anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (actualProvider === "codex") { // Codex responses are already in Anthropic format from invokeCodex anthropicPayload = databricksResponse.json; if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } } else if (databricksResponse.json?.type === "message" && Array.isArray(databricksResponse.json?.content)) { // Shape-detected: already Anthropic (some clients convert upstream). // Re-converting via toAnthropicResponse reads the absent choices[] // and empties the content. anthropicPayload = databricksResponse.json; anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } else { anthropicPayload = toAnthropicResponse( databricksResponse.json, requestedModel, wantsThinking, ); anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } // Attach routing metadata for OpenClaw model name rewriting if (databricksResponse.routingDecision) { anthropicPayload._routingMeta = { provider: databricksResponse.routingDecision.provider, model: databricksResponse.routingDecision.model, tier: databricksResponse.routingDecision.tier, score: databricksResponse.routingDecision.score ?? null, }; } appendTurnToSession(session, { role: "assistant", type: "message", status: 200, content: anthropicPayload, metadata: { termination: "completion" }, }); if (cacheKey && steps === 1) { const storedKey = promptCache.storeResponse(cacheKey, databricksResponse); if (storedKey) { const promptTokens = databricksResponse.json?.usage?.prompt_tokens ?? 0; anthropicPayload.usage.cache_creation_input_tokens = promptTokens; } } // === MEMORY EXTRACTION (Titans-inspired long-term memory) === if (config.memory?.enabled !== false && config.memory?.extraction?.enabled !== false) { setImmediate(async () => { try { const memoryExtractor = require('../memory/extractor'); const extractedMemories = await memoryExtractor.extractMemories( anthropicPayload, cleanPayload.messages, { sessionId: session?.id } ); if (extractedMemories.length > 0) { logger.debug({ sessionId: session?.id, memoriesExtracted: extractedMemories.length, }, 'Extracted and stored long-term memories'); } } catch (err) { logger.warn({ err, sessionId: session?.id }, 'Memory extraction failed'); } }); } const finalDurationMs = Date.now() - start; logger.info( { sessionId: session?.id ?? null, steps, durationMs: finalDurationMs, avgDurationPerStep: steps > 0 ? Math.round(finalDurationMs / steps) : 0, }, "Agent loop completed successfully", ); if (agentTimer) { agentTimer.mark("responseReady"); agentTimer.done(); } return { response: { status: 200, body: anthropicPayload, terminationReason: "completion", }, steps, durationMs: finalDurationMs, terminationReason: "completion", }; } appendTurnToSession(session, { role: "assistant", type: "error", status: 504, content: { error: "max_steps_exceeded", message: "Reached agent loop limits without producing a response.", limits: { maxSteps: settings.maxSteps, maxDurationMs: settings.maxDurationMs, }, }, metadata: { termination: "max_steps" }, }); const finalDurationMs = Date.now() - start; logger.warn( { sessionId: session?.id ?? null, steps, durationMs: finalDurationMs, maxSteps: settings.maxSteps, maxDurationMs: settings.maxDurationMs, }, "Agent loop exceeded limits", ); return { response: { status: 504, body: { error: "max_steps_exceeded", message: "Reached agent loop limits without producing a response.", limits: { maxSteps: settings.maxSteps, maxDurationMs: settings.maxDurationMs, }, metrics: { steps, durationMs: finalDurationMs, }, }, terminationReason: "max_steps", }, steps, durationMs: finalDurationMs, terminationReason: "max_steps", }; } /** * Detect if the current request is a suggestion mode call. * Scans the last user message for the [SUGGESTION MODE: marker. * @param {Array} messages - The conversation messages * @returns {{ isSuggestionMode: boolean }} */ function detectSuggestionMode(messages) { if (!Array.isArray(messages) || messages.length === 0) { return { isSuggestionMode: false }; } // Scan from the end to find the last user message for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role !== 'user') continue; const content = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? msg.content.map(b => b.text || '').join(' ') : ''; if (content.includes('[SUGGESTION MODE:')) { return { isSuggestionMode: true }; } // Only check the last user message break; } return { isSuggestionMode: false }; } async function processMessage({ payload, headers, session, cwd, options = {} }) { const requestedModel = payload?.model ?? config.modelProvider?.defaultModel ?? "claude-3-unknown"; const wantsThinking = typeof headers?.["anthropic-beta"] === "string" && headers["anthropic-beta"].includes("interleaved-thinking"); // === SUGGESTION MODE: Early return when SUGGESTION_MODE_MODEL=none === const { isSuggestionMode } = detectSuggestionMode(payload?.messages); const suggestionModelConfig = config.modelProvider?.suggestionModeModel ?? "default"; if (isSuggestionMode && suggestionModelConfig.toLowerCase() === "none") { logger.info('Suggestion mode: skipping LLM call (SUGGESTION_MODE_MODEL=none)'); return { response: { json: { id: `msg_suggestion_skip_${Date.now()}`, type: "message", role: "assistant", content: [{ type: "text", text: "" }], model: requestedModel, stop_reason: "end_turn", stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 }, }, ok: true, status: 200, }, steps: 0, durationMs: 0, terminationReason: "suggestion_mode_skip", }; } // === REQUEST BYPASS === // Claude CLI housekeeping (Warmup pings, topic/title extraction) doesn't // need a model call — return a canned response and skip the provider. const bypass = detectBypass({ payload, headers }); if (bypass) { return buildBypassResponse(bypass, requestedModel); } // === PREFLIGHT CHECK === // If the request supplied preflight_commands and they all pass in // the workspace, the work is already done — short-circuit with a // synthetic response and never touch the model. No-op when the // feature is disabled or the request didn't opt in. const preflightResult = tryPreflight({ payload, cwd }); if (preflightResult?.satisfied) { logger.info({ commands: preflightResult.results.length, reason: preflightResult.reason, }, '[Preflight] Satisfied — skipping model call'); return buildPreflightResponse({ model: requestedModel, preflightResult, }); } if (preflightResult && !preflightResult.satisfied) { logger.debug({ failedCommand: preflightResult.failedCommand, }, '[Preflight] Not satisfied — proceeding with model call'); } // === TOOL LOOP GUARD (EARLY CHECK) === // Check BEFORE sanitization since sanitizePayload removes conversation history if (session) { // === CROSS-REQUEST DEDUP === // The inner-loop guard resets each HTTP request so repeated calls across // requests escape detection. Track signatures in session metadata instead. ensureDedupStructure(session); // Detect new user question → reset dedup tracking const dedup = session.metadata.toolCallDedup; const incomingToolUse = extractToolUseFromCurrentTurn(payload?.messages); // A user text message with no preceding tool_use means a brand-new question const hasNewUserText = (() => { const msgs = payload?.messages || []; for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i]; if (msg?.role === 'user') { if (typeof msg.content === 'string' && msg.content.trim().length > 0) return true; if (Array.isArray(msg.content)) { return msg.content.some(block => (block?.type === 'text' && block?.text?.trim?.().length > 0) || (block?.type === 'input_text' && block?.input_text?.trim?.().length > 0) ); } } break; // Only check the very last message } return false; })(); if (hasNewUserText && incomingToolUse.length === 0) { // Pure user text with no tool results → new question resetDedupTracking(session); } else { // Record each tool_use from the incoming messages into the dedup tracker for (const toolUseBlock of incomingToolUse) { recordCrossRequestToolCall(session, toolUseBlock); } const { maxCount, toolName: dedupToolName, signature: dedupSig } = getMaxDedupCount(session); if (maxCount >= DEDUP_TERMINATE_THRESHOLD) { // Force-terminate: same pattern as existing tool_loop_guard logger.error({ toolName: dedupToolName, count: maxCount, threshold: DEDUP_TERMINATE_THRESHOLD, signature: dedupSig, sessionId: session?.id ?? null, }, "[CrossRequestDedup] FORCE TERMINATING - repeated tool call across requests"); // Extract tool results summary from current turn let toolResultsSummary = ""; const messages = payload?.messages || []; const { lastUserTextIndex: luIdx } = countToolCallsInHistory(messages); const startIdx = luIdx >= 0 ? luIdx : 0; for (let i = startIdx; i < messages.length; i++) { const msg = messages[i]; if (!msg || !Array.isArray(msg.content)) continue; for (const block of msg.content) { if (block?.type === 'tool_result' && block?.content) { const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content); if (content && !content.includes('Found 0')) { toolResultsSummary += content + "\n"; } } } } let responseText = `Based on the tool results, here's what I found:\n\n`; if (toolResultsSummary.trim()) { responseText += toolResultsSummary.trim(); } else { responseText += `The tools executed but didn't return clear results. Please check the tool output above or try a different command.`; } const forcedResponse = { id: `msg_forced_${Date.now()}`, type: "message", role: "assistant", content: [{ type: "text", text: responseText }], model: requestedModel || "unknown", stop_reason: "end_turn", stop_sequence: null, usage: { input_tokens: 0, output_tokens: 100 }, }; // Reset dedup after termination so next question starts fresh resetDedupTracking(session); // Persist to DB (non-ephemeral sessions only) if (session.id && !session._ephemeral) { try { upsertSession(session.id, { metadata: session.metadata }); } catch (e) { logger.debug({ err: e.message }, "Failed to persist dedup reset"); } } return { status: 200, body: forcedResponse, terminationReason: "tool_loop_guard", }; } if (maxCount >= DEDUP_WARN_THRESHOLD && !dedup.warningInjected) { logger.warn({ toolName: dedupToolName, count: maxCount, threshold: DEDUP_WARN_THRESHOLD, signature: dedupSig, sessionId: session?.id ?? null, }, "[CrossRequestDedup] Warning - repeated tool call detected across requests"); dedup.warningInjected = true; // Inject a strict warning into the payload so the model sees it if (Array.isArray(payload?.messages)) { payload.messages.push({ role: "user", content: `⚠️ CRITICAL SYSTEM WARNING: You have called the "${dedupToolName}" tool ${maxCount} times with identical or similar parameters across multiple requests. This IS an infinite loop. STOP calling this tool immediately. You MUST now provide a direct text response based on the results you have received. If the tool returned "no results" or empty output, that IS the final answer - do not retry. Your response must contain your actual findings from the tool results gathered so far — NOT an acknowledgment or restatement of this warning. Do not mention this warning in your response.`, }); } } // Persist dedup state (non-ephemeral sessions only) if (session.id && !session._ephemeral) { try { upsertSession(session.id, { metadata: session.metadata }); } catch (e) { logger.debug({ err: e.message }, "Failed to persist dedup state"); } } } // No count-based tool_loop_guard. Natural limits (maxSteps, maxDurationMs, // provider token/rate limits, client-side loop detection, and the // cross-request dedup above) are sufficient protection. } const { createTimer } = require("../utils/perf-timer"); const pTimer = createTimer("processMessage"); const cleanPayload = sanitizePayload(payload); pTimer.mark("sanitizePayload"); appendTurnToSession(session, { role: "user", content: { raw: payload?.messages ?? [], normalized: cleanPayload.messages, }, type: "message", }); pTimer.mark("sessionAppend"); let cacheKey = null; let cachedResponse = null; if (promptCache.isEnabled()) { // cleanPayload is already a deep clone from sanitizePayload, no need to clone again const { key, entry } = promptCache.lookup(cleanPayload); pTimer.mark("cacheCheck"); cacheKey = key; if (entry?.value) { try { cachedResponse = structuredClone(entry.value); } catch { cachedResponse = entry.value; } } } if (cachedResponse) { // Same shape guard as the live path: cached json may already be Anthropic. const anthropicPayload = (cachedResponse.json?.type === "message" && Array.isArray(cachedResponse.json?.content)) ? cachedResponse.json : toAnthropicResponse( cachedResponse.json, requestedModel, wantsThinking, ); anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); const promptTokens = cachedResponse.json?.usage?.prompt_tokens ?? 0; const completionTokens = cachedResponse.json?.usage?.completion_tokens ?? 0; anthropicPayload.usage.input_tokens = promptTokens; anthropicPayload.usage.output_tokens = completionTokens; anthropicPayload.usage.cache_read_input_tokens = promptTokens; anthropicPayload.usage.cache_creation_input_tokens = 0; // Carry routing metadata on the cache-hit path too, so downstream model // name resolution (OpenClaw) behaves the same as the live loop path. if (cachedResponse.routingDecision) { anthropicPayload._routingMeta = { provider: cachedResponse.routingDecision.provider, model: cachedResponse.routingDecision.model, tier: cachedResponse.routingDecision.tier, score: cachedResponse.routingDecision.score ?? null, }; } appendTurnToSession(session, { role: "assistant", type: "message", status: 200, content: anthropicPayload, metadata: { termination: "completion", cacheHit: true }, }); logger.info( { sessionId: session?.id ?? null, cacheKey, }, "Agent response served from prompt cache", ); return { status: 200, body: anthropicPayload, terminationReason: "completion", }; } let semanticLookupResult = null; const semanticCache = getSemanticCache(); if (semanticCache.isEnabled()) { try { semanticLookupResult = await semanticCache.lookup(cleanPayload.messages); if (semanticLookupResult.hit) { const cachedBody = semanticLookupResult.response; logger.info({ sessionId: session?.id ?? null, similarity: semanticLookupResult.similarity?.toFixed(4), }, "Agent response served from semantic cache"); appendTurnToSession(session, { role: "assistant", type: "message", status: 200, content: cachedBody, metadata: { termination: "completion", semanticCacheHit: true, similarity: semanticLookupResult.similarity, }, }); return { status: 200, // Spread so the marker never mutates the stored cache entry. // Anthropic clients ignore unknown top-level fields; benchmarks // and dashboards use this to attribute cache hits honestly — // previously a hit was indistinguishable from a full-price call // in the response body (usage echoed the cached values). body: { ...cachedBody, lynkr_semantic_cache: { hit: true, similarity: semanticLookupResult.similarity ?? null } }, terminationReason: "completion", }; } } catch (err) { logger.debug({ error: err.message }, "Semantic cache lookup failed, continuing without"); } } pTimer.mark("preAgentLoop"); const loopResult = await runAgentLoop({ cleanPayload, requestedModel, wantsThinking, session, cwd, options, cacheKey, providerType: config.modelProvider?.type ?? "databricks", headers, }); pTimer.mark("agentLoopDone"); pTimer.done(); if (semanticCache.isEnabled() && semanticLookupResult && !semanticLookupResult.hit) { if (loopResult.response?.status === 200 && loopResult.response?.body) { try { await semanticCache.store(semanticLookupResult, loopResult.response.body); } catch (err) { logger.debug({ error: err.message }, "Semantic cache store failed"); } } } return loopResult.response; } /** * Trim an over-long agent-loop conversation while ALWAYS preserving the * current task: the latest user message with real typed text. Keeps the * 2-message head (opening context), the task message (re-inserted if it * would fall in the trimmed middle), and the most recent tail. Boundary * orphans (tool_results whose tool_use was trimmed) are handled by the * converters' existing orphan-droppers. */ function trimLoopMessages(messages, max) { const { extractCleanUserText } = require("../routing/intent-score"); let taskIdx = -1; for (let i = messages.length - 1; i >= 2; i--) { if (messages[i]?.role === "user" && extractCleanUserText({ messages: [messages[i]] })) { taskIdx = i; break; } } const head = messages.slice(0, 2); const keepTail = max - head.length - 1; const tailStart = messages.length - keepTail; const taskMsg = taskIdx >= 2 && taskIdx < tailStart ? [messages[taskIdx]] : []; const trimmed = [...head, ...taskMsg, ...messages.slice(tailStart)]; logger.debug( { trimmed: messages.length - trimmed.length, remaining: trimmed.length, taskKept: taskMsg.length > 0 || taskIdx >= tailStart || taskIdx < 2 }, "Trimmed intermediate messages to prevent memory growth", ); return trimmed; } module.exports = { processMessage, // Exported for unit testing of response-metadata conversion. toAnthropicResponse, // Exported for unit testing of loop trimming (task-preservation contract). trimLoopMessages, // Exported for unit testing of the live-stream routing badge. buildRoutingBadge, };