lynkr 9.5.0 → 9.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  const logger = require("../logger");
2
+ const { repairToolCallIds } = require("./tool-call-repair");
2
3
 
3
4
  /**
4
5
  * Convert Anthropic tool format to OpenAI/OpenRouter format
@@ -146,35 +147,11 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
146
147
  }
147
148
  }
148
149
 
149
- // Fix tool_call_id mismatches: ensure every tool message's tool_call_id
150
- // matches the id in the preceding assistant's tool_calls array.
151
- // IDs can drift when multiple conversion layers (Anthropic↔OpenAI) each
152
- // generate their own IDs.
153
- for (let i = 0; i < converted.length; i++) {
154
- const msg = converted[i];
155
- if (msg.role !== 'tool') continue;
156
-
157
- // Find the nearest preceding assistant with tool_calls
158
- for (let j = i - 1; j >= 0; j--) {
159
- const prev = converted[j];
160
- if (prev.role === 'user') break;
161
- if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
162
- if (!prev.tool_calls.some(tc => tc.id === msg.tool_call_id)) {
163
- // Mismatch — pick the first unmatched tool_call id
164
- const usedIds = new Set();
165
- for (let k = j + 1; k < converted.length; k++) {
166
- if (converted[k].role === 'tool' && k !== i) usedIds.add(converted[k].tool_call_id);
167
- }
168
- const available = prev.tool_calls.find(tc => !usedIds.has(tc.id));
169
- if (available) {
170
- logger.info({ from: msg.tool_call_id, to: available.id }, "Fixed tool_call_id mismatch");
171
- msg.tool_call_id = available.id;
172
- }
173
- }
174
- break;
175
- }
176
- }
177
- }
150
+ // Repair tool_call_id linkage before handing to OpenAI-compatible providers:
151
+ // backfill blank assistant tool_call ids, re-link drifted/blank tool_call_ids
152
+ // to the nearest preceding assistant tool_call, and drop orphan tool results.
153
+ // Moonshot/Kimi (and others) hard-400 on any empty or unmatched tool_call_id.
154
+ repairToolCallIds(converted);
178
155
 
179
156
  // Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose
180
157
  // content is an empty string with "Invalid request: tokenization failed".
@@ -119,6 +119,51 @@ function needsCacheInjection(provider) {
119
119
  return EXPLICIT_CACHE_PROVIDERS.has(provider);
120
120
  }
121
121
 
122
+ // Model families that do NOT support cache_control breakpoints. cache_control
123
+ // is an Anthropic construct; on aggregating providers (Bedrock, OpenRouter) it
124
+ // only applies to models that natively understand it (Claude, and Gemini via
125
+ // proxy). Injecting markers onto these families produces request shapes the
126
+ // upstream model rejects or silently ignores.
127
+ const NON_CACHE_MODEL_PATTERNS = [
128
+ /(^|[./-])titan/i,
129
+ /(^|[./-])nova/i,
130
+ /(^|[./-])llama/i,
131
+ /(^|[./-])mistral/i,
132
+ /(^|[./-])mixtral/i,
133
+ /(^|[./-])cohere/i,
134
+ /(^|[./-])command/i, // cohere command-*
135
+ /(^|[./-])j2/i, // ai21 jurassic
136
+ /(^|[./-])jamba/i,
137
+ /(^|[./-])deepseek/i,
138
+ /(^|[./-])qwen/i,
139
+ /(^|[./-])gpt/i,
140
+ /(^|[./-])openai/i,
141
+ ];
142
+
143
+ /**
144
+ * Determine whether the model targeted by this request supports cache_control.
145
+ *
146
+ * Some providers in EXPLICIT_CACHE_PROVIDERS (notably bedrock and openrouter)
147
+ * route to many model families, only some of which understand cache_control.
148
+ * This guard inspects the resolved model id and blocks injection for families
149
+ * that are known not to support it. When the model id is absent or
150
+ * unrecognized, injection is allowed (fail-open) — Claude/Gemini-style ids and
151
+ * Anthropic-only providers fall through to true.
152
+ *
153
+ * @param {Object} body - Request body (may carry the resolved model id)
154
+ * @param {string} provider - Provider name
155
+ * @returns {boolean}
156
+ */
157
+ function modelSupportsCacheControl(body, provider) {
158
+ // Providers that only ever route to Anthropic models always support it.
159
+ if (provider === 'azure-anthropic' || provider === 'databricks') return true;
160
+
161
+ const modelId = body && (body._tierModel || body.model);
162
+ if (!modelId || typeof modelId !== 'string') return true; // unknown → fail open
163
+
164
+ return !NON_CACHE_MODEL_PATTERNS.some(re => re.test(modelId));
165
+ }
166
+
122
167
  /**
123
168
  * Inject provider-side prompt caching into the request body.
124
169
  * Call this before sending to the provider.
@@ -129,12 +174,31 @@ function needsCacheInjection(provider) {
129
174
  */
130
175
  function injectPromptCaching(body, provider) {
131
176
  if (!needsCacheInjection(provider)) return 0;
177
+ // Gate on model capability: a provider may support cache_control in general
178
+ // while the specific routed model does not.
179
+ if (!modelSupportsCacheControl(body, provider)) return 0;
180
+ // If the client (e.g. Claude Code) already attached cache_control breakpoints,
181
+ // don't add more. Anthropic caps at 4 breakpoints per request and stacking ours
182
+ // on top has caused 400/429 errors on OAuth subscription requests.
183
+ if (hasExistingCacheControl(body)) return 0;
132
184
  return injectAnthropicCacheBreakpoints(body);
133
185
  }
134
186
 
187
+ function hasExistingCacheControl(body) {
188
+ if (!body) return false;
189
+ const scan = (obj) => {
190
+ if (!obj || typeof obj !== 'object') return false;
191
+ if (Array.isArray(obj)) return obj.some(scan);
192
+ if (obj.cache_control) return true;
193
+ return Object.values(obj).some(scan);
194
+ };
195
+ return scan(body.system) || scan(body.messages) || scan(body.tools);
196
+ }
197
+
135
198
  module.exports = {
136
199
  injectPromptCaching,
137
200
  injectAnthropicCacheBreakpoints,
138
201
  injectGeminiCacheBreakpoints,
139
202
  needsCacheInjection,
203
+ modelSupportsCacheControl,
140
204
  };
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const logger = require("../logger");
11
+ const { repairToolCallIds } = require("./tool-call-repair");
11
12
 
12
13
  /**
13
14
  * Map client tool names back to Lynkr tool names
@@ -203,6 +204,12 @@ function convertResponsesToChat(responsesRequest) {
203
204
  return cleaned;
204
205
  });
205
206
 
207
+ // Repair tool_call_id linkage now, before anything downstream consumes the
208
+ // converted array. Codex bundled-plugin (Browser/Computer-use) calls and
209
+ // synthetic tool outputs can arrive without a usable call_id, which
210
+ // otherwise flattens to a blank tool_call_id and 400s at the provider.
211
+ repairToolCallIds(messages);
212
+
206
213
  logger.info({
207
214
  originalCount: input.length,
208
215
  filteredCount: messages.length,
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tool-call id repair for OpenAI-format message arrays.
3
+ *
4
+ * OpenAI-compatible providers (Moonshot/Kimi, OpenAI, OpenRouter, …) reject a
5
+ * request with `Invalid request: tool_call_id <x> is not found` whenever a
6
+ * `tool` message references an id that has no matching entry in a preceding
7
+ * assistant `tool_calls` array. This happens in practice when:
8
+ *
9
+ * 1. A `tool` message's tool_call_id is empty/missing — e.g. Codex Desktop's
10
+ * bundled-plugin (Browser/Computer-use) function_call_output items, and
11
+ * synthetic "unsupported call: shell" outputs, arrive without a usable
12
+ * `call_id`, so both the assistant tool_call id and the tool_call_id
13
+ * flatten to "". (The error shows a blank id: "tool_call_id is not found".)
14
+ * 2. An assistant tool_calls entry has an empty/missing id.
15
+ * 3. Ids drift across multiple conversion layers (Responses↔Chat↔Anthropic),
16
+ * leaving a `tool` message pointing at an id no assistant ever issued.
17
+ *
18
+ * This helper repairs all three in place: it backfills synthetic ids onto
19
+ * assistant tool_calls that lack one, re-links each `tool` message to an unused
20
+ * tool_call id on the nearest preceding assistant, and drops any `tool` message
21
+ * that has no assistant tool_call to attach to (a dangling result is a hard
22
+ * 400 at the provider, so dropping it is strictly safer than forwarding it).
23
+ *
24
+ * @module clients/tool-call-repair
25
+ */
26
+
27
+ const logger = require("../logger");
28
+
29
+ function isBlankId(id) {
30
+ return !id || String(id).trim() === "";
31
+ }
32
+
33
+ /**
34
+ * Repair tool_call_id linkage in an OpenAI chat-format message array, in place.
35
+ *
36
+ * @param {Array} messages - OpenAI chat-format messages (role/content, with
37
+ * assistant `tool_calls` and `tool` `tool_call_id`). Mutated in place.
38
+ * @returns {Array} the same array reference, with orphan tool messages removed.
39
+ */
40
+ function repairToolCallIds(messages) {
41
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
42
+
43
+ let synthCounter = 0;
44
+ const nextSyntheticId = () => `call_auto_${synthCounter++}`;
45
+
46
+ // Pass 1 — guarantee every assistant tool_call has a non-empty id.
47
+ for (const msg of messages) {
48
+ if (msg && msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
49
+ for (const tc of msg.tool_calls) {
50
+ if (tc && isBlankId(tc.id)) {
51
+ tc.id = nextSyntheticId();
52
+ logger.info({ assignedId: tc.id }, "Backfilled missing assistant tool_call id");
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // Pass 2 — relink (or drop) every tool message.
59
+ const repaired = [];
60
+ let dropped = 0;
61
+ for (let i = 0; i < messages.length; i++) {
62
+ const msg = messages[i];
63
+ if (!msg || msg.role !== "tool") {
64
+ repaired.push(msg);
65
+ continue;
66
+ }
67
+
68
+ // Nearest preceding assistant that carries tool_calls (stop at a user turn).
69
+ let assistant = null;
70
+ for (let j = i - 1; j >= 0; j--) {
71
+ const prev = messages[j];
72
+ if (!prev) continue;
73
+ if (prev.role === "user") break;
74
+ if (prev.role === "assistant" && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
75
+ assistant = prev;
76
+ break;
77
+ }
78
+ }
79
+
80
+ const matches =
81
+ assistant &&
82
+ !isBlankId(msg.tool_call_id) &&
83
+ assistant.tool_calls.some((tc) => tc.id === msg.tool_call_id);
84
+
85
+ if (matches) {
86
+ repaired.push(msg);
87
+ continue;
88
+ }
89
+
90
+ if (assistant) {
91
+ // Pick the first tool_call id not already consumed by an earlier result.
92
+ const usedIds = new Set(
93
+ repaired.filter((r) => r && r.role === "tool" && r.tool_call_id).map((r) => r.tool_call_id)
94
+ );
95
+ const available = assistant.tool_calls.find((tc) => !usedIds.has(tc.id));
96
+ if (available) {
97
+ logger.info(
98
+ { from: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id, to: available.id },
99
+ "Repaired tool_call_id linkage"
100
+ );
101
+ msg.tool_call_id = available.id;
102
+ repaired.push(msg);
103
+ continue;
104
+ }
105
+ }
106
+
107
+ // No assistant tool_call to attach to — drop the orphan rather than let it
108
+ // 400 the whole request at the provider.
109
+ dropped++;
110
+ logger.warn(
111
+ {
112
+ tool_call_id: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id,
113
+ contentPreview: typeof msg.content === "string" ? msg.content.slice(0, 80) : "",
114
+ },
115
+ "Dropped orphan tool message with no matching tool_call"
116
+ );
117
+ }
118
+
119
+ if (dropped > 0) {
120
+ logger.info({ dropped, before: messages.length, after: repaired.length }, "Removed orphan tool messages");
121
+ }
122
+
123
+ // Rewrite the array contents in place so callers holding this reference see
124
+ // the repaired result.
125
+ messages.length = 0;
126
+ for (const m of repaired) messages.push(m);
127
+ return messages;
128
+ }
129
+
130
+ module.exports = { repairToolCallIds, isBlankId };
@@ -518,6 +518,12 @@ const agentsDefaultModel = process.env.AGENTS_DEFAULT_MODEL ?? "haiku";
518
518
  const agentsMaxSteps = Number.parseInt(process.env.AGENTS_MAX_STEPS ?? "15", 10);
519
519
  const agentsTimeout = Number.parseInt(process.env.AGENTS_TIMEOUT ?? "120000", 10);
520
520
 
521
+ // Task decomposition configuration (opt-in; builds on the agents subsystem).
522
+ // Single env toggle; everything else is hardcoded below.
523
+ const taskDecompositionEnabled = process.env.TASK_DECOMPOSITION_ENABLED === "true";
524
+
525
+ // Tier-aware fallback (escalate-then-demote) is always on (hardcoded).
526
+
521
527
  // LLM Audit logging configuration
522
528
  const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
523
529
  const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
@@ -775,6 +781,23 @@ var config = {
775
781
  maxSteps: Number.isNaN(agentsMaxSteps) ? 15 : agentsMaxSteps,
776
782
  timeout: Number.isNaN(agentsTimeout) ? 120000 : agentsTimeout,
777
783
  },
784
+ taskDecomposition: {
785
+ enabled: taskDecompositionEnabled, // only env-driven knob (TASK_DECOMPOSITION_ENABLED)
786
+ // Hardcoded defaults — tune here if needed.
787
+ shadow: false,
788
+ planModel: "sonnet",
789
+ synthModel: "sonnet",
790
+ minConfidence: 0.5,
791
+ gate: {
792
+ minComplexity: 60,
793
+ minTokens: 3000,
794
+ maxSubtasks: 6,
795
+ minIndependentUnits: 2,
796
+ },
797
+ },
798
+ tierFallback: {
799
+ enabled: true, // always on (hardcoded): escalate-then-demote on provider failure; floor = SIMPLE
800
+ },
778
801
  tests: {
779
802
  defaultCommand: testDefaultCommand ? testDefaultCommand.trim() : null,
780
803
  defaultArgs: testDefaultArgs,
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Output Format Guard
3
+ *
4
+ * Appends a short formatting instruction to the system prompt so weaker
5
+ * backends (Moonshot/Kimi, Ollama, etc.) stop emitting mangled ASCII/Unicode
6
+ * box-drawing "diagrams" that render as garbage in clients. The actual backend
7
+ * is decided by tier routing — so even when the client asks for "claude-opus",
8
+ * the request may be served by a model that formats poorly. This normalizes the
9
+ * presentation without changing which model serves the request.
10
+ *
11
+ * Always-on (no env flag). It is skipped only for Claude-family backends, which
12
+ * already produce clean GitHub-flavored markdown — injecting there would just
13
+ * waste tokens. The skip biases toward injecting: if we can't tell, we inject
14
+ * (a false-inject is harmless ~50 tokens; a false-skip leaves the garble).
15
+ *
16
+ * Keyed off the ROUTING-RESOLVED provider/model, never the client's requested
17
+ * body.model (which is just a label once tier routing is on).
18
+ *
19
+ * @module context/output-format-guard
20
+ */
21
+
22
+ const logger = require("../logger");
23
+
24
+ const MARKER = "[fmt-guard]";
25
+
26
+ // Model names that already produce clean markdown.
27
+ const CLEAN_FORMATTER_RE = /\b(claude|sonnet|opus|haiku)\b/i;
28
+ // Providers that are always Claude-backed.
29
+ const CLEAN_PROVIDERS = new Set(["azure-anthropic"]);
30
+
31
+ const GUARD_TEXT =
32
+ `${MARKER} Formatting rules for your response: use plain GitHub-flavored markdown only. ` +
33
+ "Do NOT draw diagrams or boxes with ASCII or Unicode line-drawing characters " +
34
+ "(such as ┌ ─ │ └ ├ ┤ ┬ ┴ ╔ ═ ║), and do NOT wrap headings or code in decorative borders. " +
35
+ "Represent structure and relationships with normal markdown headings, nested bullet lists, " +
36
+ "numbered lists, or tables. Use standard triple-backtick fenced code blocks for code. " +
37
+ "Keep code, file paths, commands, and URLs exact.";
38
+
39
+ /**
40
+ * Whether the resolved backend already formats cleanly (→ skip injection).
41
+ * @param {string} provider - routing-resolved provider
42
+ * @param {string} model - routing-resolved model (NOT the client's requested model)
43
+ * @returns {boolean}
44
+ */
45
+ function producesCleanMarkdown(provider, model) {
46
+ if (CLEAN_PROVIDERS.has(String(provider || "").toLowerCase())) return true;
47
+ if (model && CLEAN_FORMATTER_RE.test(String(model))) return true;
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Append the guard text to a system prompt that may be a string or an array of
53
+ * Anthropic content blocks. Idempotent via MARKER. Pure — returns the new value.
54
+ */
55
+ function appendToSystem(system, text) {
56
+ // String (or empty) system prompt.
57
+ if (system == null || typeof system === "string") {
58
+ const base = system || "";
59
+ if (base.includes(MARKER)) return base;
60
+ return base ? `${base}\n\n${text}` : text;
61
+ }
62
+ // Anthropic array-of-blocks system prompt.
63
+ if (Array.isArray(system)) {
64
+ const already = system.some(
65
+ (b) => typeof b?.text === "string" && b.text.includes(MARKER)
66
+ );
67
+ if (already) return system;
68
+ return [...system, { type: "text", text }];
69
+ }
70
+ return system;
71
+ }
72
+
73
+ /**
74
+ * Inject the formatting guard into body.system in place, unless the resolved
75
+ * backend already formats cleanly. Always-on; idempotent.
76
+ *
77
+ * @param {object} body - request body (mutated in place)
78
+ * @param {object} [opts]
79
+ * @param {string} [opts.provider] - routing-resolved provider
80
+ * @param {string} [opts.model] - routing-resolved model
81
+ * @returns {object} body
82
+ */
83
+ function injectFormatGuard(body, opts = {}) {
84
+ if (!body) return body;
85
+ const { provider, model } = opts;
86
+ if (producesCleanMarkdown(provider, model)) return body;
87
+
88
+ body.system = appendToSystem(body.system, GUARD_TEXT);
89
+ logger.debug({ provider, model }, "[FormatGuard] Injected markdown formatting guard");
90
+ return body;
91
+ }
92
+
93
+ module.exports = {
94
+ injectFormatGuard,
95
+ producesCleanMarkdown,
96
+ appendToSystem,
97
+ MARKER,
98
+ GUARD_TEXT,
99
+ };
@@ -965,22 +965,48 @@ function stripThinkingBlocks(text) {
965
965
  /**
966
966
  * Convert legacy Ollama /api/chat response to Anthropic Messages format.
967
967
  * Used when Ollama < v0.14.0 (no native Anthropic endpoint).
968
+ *
969
+ * Critical for MiniMax M2/M2.5 (and other interleaved-thinking models):
970
+ * preserve <think>...</think> from message.content AND Ollama's native
971
+ * message.thinking field as Anthropic thinking blocks. Dropping them breaks
972
+ * the model's long-horizon agent loop — vendor-quantified at Tau^2 -35.9%,
973
+ * BrowseComp -40.1% (https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2).
968
974
  */
969
975
  function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
970
976
  const message = ollamaResponse?.message ?? {};
971
- const rawContent = message.content || "";
977
+ const rawContent = typeof message.content === "string" ? message.content : "";
978
+ const nativeThinking = typeof message.thinking === "string" ? message.thinking : "";
972
979
  const toolCalls = message.tool_calls || [];
973
980
 
981
+ // Extract <think>...</think> blocks from content (concatenate if multiple).
982
+ // What remains becomes the text body.
983
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
984
+ const thinkMatches = [];
985
+ let textBody = rawContent;
986
+ let m;
987
+ while ((m = thinkRegex.exec(rawContent)) !== null) {
988
+ thinkMatches.push(m[1]);
989
+ }
990
+ textBody = textBody.replace(thinkRegex, "").trim();
991
+
992
+ const combinedThinking = [nativeThinking, ...thinkMatches]
993
+ .map(s => (s || "").trim())
994
+ .filter(Boolean)
995
+ .join("\n\n");
996
+
974
997
  const contentItems = [];
975
998
 
976
- if (typeof rawContent === "string" && rawContent.trim()) {
977
- const cleanedContent = stripThinkingBlocks(rawContent);
978
- if (cleanedContent) {
979
- contentItems.push({ type: "text", text: cleanedContent });
980
- }
999
+ // 1. Thinking block FIRST (Mini-Agent reference order: thinking text → tool_use)
1000
+ if (combinedThinking) {
1001
+ contentItems.push({ type: "thinking", thinking: combinedThinking });
981
1002
  }
982
1003
 
983
- // Convert tool calls from OpenAI function-calling format to Anthropic tool_use
1004
+ // 2. Text body (after <think> tags removed)
1005
+ if (textBody) {
1006
+ contentItems.push({ type: "text", text: textBody });
1007
+ }
1008
+
1009
+ // 3. Tool calls converted to Anthropic tool_use
984
1010
  if (Array.isArray(toolCalls) && toolCalls.length > 0) {
985
1011
  for (const toolCall of toolCalls) {
986
1012
  const func = toolCall.function || {};
@@ -1008,6 +1034,9 @@ function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
1008
1034
  const inputTokens = ollamaResponse.prompt_eval_count ?? 0;
1009
1035
  const outputTokens = ollamaResponse.eval_count ?? 0;
1010
1036
 
1037
+ // stop_reason derived from tool_calls presence, NOT done_reason.
1038
+ // Ollama emits done_reason="stop" even when tool_calls are present
1039
+ // (ollama/ollama#12557) — naive mapping would falsely halt Claude Code's loop.
1011
1040
  return {
1012
1041
  id: `msg_${Date.now()}`,
1013
1042
  type: "message",
@@ -1104,7 +1133,16 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
1104
1133
 
1105
1134
  function sanitizePayload(payload) {
1106
1135
  const { clonePayloadSmart } = require("../utils/payload");
1107
- const providerType = config.modelProvider?.type ?? "databricks";
1136
+ // Honor a forceProvider marker (set by the OAuth tier-routing path) so the
1137
+ // tool-format / system-flatten / strip-thinking branches downstream match
1138
+ // the actual destination provider, not the static MODEL_PROVIDER default.
1139
+ // Without this, a TIER_SIMPLE=ollama:... user gets the "databricks" branch
1140
+ // running normaliseTools — which wraps tools in OpenAI {type:"function",...}
1141
+ // shape, leaving Ollama with tools named "function" and a model that
1142
+ // (correctly) reports no real tools available.
1143
+ const providerType = payload?._forceProvider
1144
+ || config.modelProvider?.type
1145
+ || "databricks";
1108
1146
  const willFlatten = providerType !== "azure-anthropic";
1109
1147
  const clean = clonePayloadSmart(payload ?? {}, { willFlatten });
1110
1148
  const requestedModel =
@@ -1260,55 +1298,16 @@ function sanitizePayload(payload) {
1260
1298
  }));
1261
1299
  delete clean.tool_choice;
1262
1300
  } else if (providerType === "ollama") {
1263
- // Check if model supports tools
1264
- const { modelNameSupportsTools } = require("../clients/ollama-utils");
1265
- const modelSupportsTools = modelNameSupportsTools(config.ollama?.model);
1266
-
1267
- // Check if this is a simple conversational message (no tools needed)
1268
- const isConversational = (() => {
1269
- if (!Array.isArray(clean.messages) || clean.messages.length === 0) {
1270
- return false;
1271
- }
1272
- const lastMessage = clean.messages[clean.messages.length - 1];
1273
- if (lastMessage?.role !== "user") {
1274
- return false;
1275
- }
1276
-
1277
- const content = typeof lastMessage.content === "string"
1278
- ? lastMessage.content
1279
- : "";
1280
-
1281
- const trimmed = content.trim().toLowerCase();
1282
-
1283
- // Simple greetings
1284
- if (/^(hi|hello|hey|good morning|good afternoon|good evening|howdy|greetings)[\s\.\!\?]*$/.test(trimmed)) {
1285
- return "greeting";
1286
- }
1287
-
1288
- // Conversational phrases that don't need tools (thanks, farewells, acknowledgements)
1289
- if (/^(thanks|thank you|thx|ty|bye|goodbye|see you|ok|okay|cool|nice|great|awesome|sure|got it|sounds good|no worries|np|cheers)[\s\.\!\?]*$/.test(trimmed)) {
1290
- return "conversational";
1291
- }
1292
-
1293
- return false;
1294
- })();
1295
-
1296
- if (isConversational) {
1297
- // Strip all tools for simple conversational messages
1298
- delete clean.tools;
1299
- delete clean.tool_choice;
1300
- logger.debug({
1301
- model: config.ollama?.model,
1302
- reason: isConversational,
1303
- }, "Ollama conversational mode - tools removed");
1304
- } else if (modelSupportsTools && Array.isArray(clean.tools) && clean.tools.length > 0) {
1305
- // Keep all tools — Ollama receives them in Anthropic format (native API)
1306
- // or they get converted to OpenAI format in invokeOllama (legacy API)
1301
+ // Always pass tools through to Ollama in Anthropic format when they exist.
1302
+ // Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if
1303
+ // the underlying model doesn't actually emit tool_use blocks, the model
1304
+ // simply responds conversationally — which is the correct fallback. Don't
1305
+ // strip the tools array based on heuristics about user intent or a
1306
+ // hardcoded "model supports tools" check, both of which produce
1307
+ // tool-blind responses ("I don't have file system access") when the
1308
+ // client (Claude Code) is clearly in an agentic session.
1309
+ if (Array.isArray(clean.tools) && clean.tools.length > 0) {
1307
1310
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1308
- } else {
1309
- // Remove tools for models without tool support
1310
- delete clean.tools;
1311
- delete clean.tool_choice;
1312
1311
  }
1313
1312
  } else if (providerType === "openrouter") {
1314
1313
  // OpenRouter supports tools - keep them as-is
@@ -1902,8 +1901,29 @@ IMPORTANT TOOL USAGE RULES:
1902
1901
  }, 'Estimated token usage before model call');
1903
1902
  }
1904
1903
 
1905
- // Apply Headroom compression if enabled
1906
- if (isHeadroomEnabled() && cleanPayload.messages && cleanPayload.messages.length > 0) {
1904
+ // Apply Headroom compression if enabled.
1905
+ //
1906
+ // Headroom is configured for a single provider (HEADROOM_PROVIDER, default
1907
+ // 'anthropic'). Its Tool Crusher rewrites tool results compactly, Cache
1908
+ // Aligner restructures messages to maximize that provider's prompt-cache
1909
+ // hit pattern, and Smart Crusher does semantic compression — all tuned for
1910
+ // Anthropic. Sending the compressed output to a different model family
1911
+ // (Ollama, OpenAI, etc.) yields output the receiver reads as "garbled tool
1912
+ // result" and the agent loop stalls.
1913
+ //
1914
+ // Gate Headroom on providers matching HEADROOM_PROVIDER. By default that's
1915
+ // Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
1916
+ // analogous gate.
1917
+ const headroomProviderMap = {
1918
+ 'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
1919
+ 'openai': new Set(['azure-openai', 'openai', 'openrouter']),
1920
+ 'google': new Set(['vertex', 'openrouter']),
1921
+ };
1922
+ const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
1923
+ const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
1924
+ const headroomCompatible = headroomSafeProviders.has(providerType);
1925
+
1926
+ if (isHeadroomEnabled() && headroomCompatible && cleanPayload.messages && cleanPayload.messages.length > 0) {
1907
1927
  try {
1908
1928
  const compressionResult = await headroomCompress(
1909
1929
  cleanPayload.messages,
@@ -1945,6 +1965,12 @@ IMPORTANT TOOL USAGE RULES:
1945
1965
  } catch (headroomErr) {
1946
1966
  logger.warn({ err: headroomErr, sessionId: session?.id ?? null }, 'Headroom compression failed, using original messages');
1947
1967
  }
1968
+ } else if (isHeadroomEnabled() && !headroomCompatible) {
1969
+ logger.debug({
1970
+ providerType,
1971
+ headroomProvider,
1972
+ reason: 'provider_mismatch',
1973
+ }, 'Headroom skipped — provider does not match HEADROOM_PROVIDER family');
1948
1974
  }
1949
1975
 
1950
1976
  // Generate correlation ID for request/response pairing
@@ -2003,15 +2029,49 @@ IMPORTANT TOOL USAGE RULES:
2003
2029
 
2004
2030
  // Caveman terse-output injection (opt-in): nudge the model toward shorter
2005
2031
  // responses to reduce output tokens.
2006
- if (config.caveman?.enabled === true) {
2032
+ //
2033
+ // Default safe-set is the Claude-family + capable instruction-following
2034
+ // models. Operators can override via LYNKR_CAVEMAN_SAFE_PROVIDERS=a,b,c.
2035
+ // (Some smaller / older models read "respond like a terse caveman" too
2036
+ // literally and produce broken telegraphic English — keep them out of the
2037
+ // set if you see that degradation.)
2038
+ const DEFAULT_CAVEMAN_SAFE = [
2039
+ 'azure-anthropic',
2040
+ 'bedrock',
2041
+ 'vertex',
2042
+ 'openrouter',
2043
+ 'ollama',
2044
+ 'openai',
2045
+ 'azure-openai',
2046
+ 'moonshot',
2047
+ 'zai',
2048
+ 'databricks',
2049
+ ];
2050
+ const cavemanSafeEnv = process.env.LYNKR_CAVEMAN_SAFE_PROVIDERS;
2051
+ const CAVEMAN_SAFE_PROVIDERS = new Set(
2052
+ cavemanSafeEnv
2053
+ ? cavemanSafeEnv.split(',').map(s => s.trim()).filter(Boolean)
2054
+ : DEFAULT_CAVEMAN_SAFE
2055
+ );
2056
+ if (config.caveman?.enabled === true && CAVEMAN_SAFE_PROVIDERS.has(providerType)) {
2007
2057
  const { injectCaveman } = require("../context/caveman");
2008
2058
  cleanPayload.system = injectCaveman(cleanPayload.system);
2059
+ } else if (config.caveman?.enabled === true) {
2060
+ logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)');
2009
2061
  }
2010
2062
 
2011
2063
  if (agentTimer) agentTimer.mark("preInvokeModel");
2012
2064
  let databricksResponse;
2065
+ // Honor a body-level forceProvider marker (set by the OAuth tier-routing
2066
+ // path in the router) so the orchestrator's internal tier router can't
2067
+ // re-pick a different provider mid-flight.
2068
+ const invokeOpts = { headers };
2069
+ if (cleanPayload._forceProvider) {
2070
+ invokeOpts.forceProvider = cleanPayload._forceProvider;
2071
+ delete cleanPayload._forceProvider;
2072
+ }
2013
2073
  try {
2014
- databricksResponse = await invokeModel(cleanPayload);
2074
+ databricksResponse = await invokeModel(cleanPayload, invokeOpts);
2015
2075
  if (agentTimer) agentTimer.mark("invokeModel");
2016
2076
  } catch (modelError) {
2017
2077
  const isConnectionError = modelError.cause?.code === 'ECONNREFUSED'