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,
};