lynkr 9.4.6 → 9.6.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.
Files changed (35) hide show
  1. package/README.md +49 -15
  2. package/install.sh +21 -5
  3. package/package.json +4 -2
  4. package/public/dashboard.html +13 -1
  5. package/scripts/check-native.js +97 -0
  6. package/src/agents/decomposition/dispatcher.js +185 -0
  7. package/src/agents/decomposition/gate.js +136 -0
  8. package/src/agents/decomposition/index.js +183 -0
  9. package/src/agents/decomposition/model-call.js +75 -0
  10. package/src/agents/decomposition/planner.js +223 -0
  11. package/src/agents/decomposition/synthesizer.js +89 -0
  12. package/src/agents/decomposition/telemetry.js +55 -0
  13. package/src/clients/databricks.js +215 -4
  14. package/src/clients/openrouter-utils.js +19 -27
  15. package/src/clients/prompt-cache-injection.js +49 -0
  16. package/src/clients/responses-format.js +7 -0
  17. package/src/clients/tool-call-repair.js +130 -0
  18. package/src/config/index.js +32 -0
  19. package/src/context/caveman.js +94 -0
  20. package/src/context/output-format-guard.js +99 -0
  21. package/src/context/tool-dedup.js +95 -0
  22. package/src/context/tool-result-compressor.js +106 -0
  23. package/src/dashboard/api.js +69 -18
  24. package/src/orchestrator/bypass.js +135 -0
  25. package/src/orchestrator/index.js +33 -2
  26. package/src/routing/index.js +47 -0
  27. package/src/routing/model-registry.js +89 -26
  28. package/src/routing/risk-analyzer.js +7 -2
  29. package/src/routing/session-affinity.js +96 -0
  30. package/src/routing/telemetry.js +16 -3
  31. package/src/routing/tier-fallback.js +91 -0
  32. package/src/server.js +2 -0
  33. package/src/tools/decompose.js +91 -0
  34. package/src/tools/lazy-loader.js +8 -0
  35. package/.impeccable/live/config.json +0 -8
@@ -1104,6 +1104,64 @@ async function invokeLMStudio(body) {
1104
1104
  return performJsonRequest(endpoint, { headers, body: lmstudioBody }, "LM Studio");
1105
1105
  }
1106
1106
 
1107
+ /**
1108
+ * Flatten an Anthropic-style content value into a plain string for the
1109
+ * Bedrock Converse API.
1110
+ *
1111
+ * Prompt-cache injection (injectPromptCaching) rewrites string `system`
1112
+ * fields and message `content` into arrays of `{ type, text, cache_control }`
1113
+ * blocks. The Converse API has no `cache_control` concept and expects
1114
+ * `system: [{ text: "<string>" }]` and message content blocks shaped as
1115
+ * `{ text: "<string>" }`. Passing the injected array through unchanged would
1116
+ * either drop the cache markers silently or nest an array under `text`,
1117
+ * producing a ValidationException.
1118
+ *
1119
+ * @param {string|Array|undefined} value - String or array of content blocks
1120
+ * @returns {string} Concatenated plain text
1121
+ */
1122
+ function flattenContentToText(value) {
1123
+ if (value == null) return "";
1124
+ if (typeof value === "string") return value;
1125
+ if (Array.isArray(value)) {
1126
+ return value
1127
+ .map(block => {
1128
+ if (typeof block === "string") return block;
1129
+ if (block && typeof block === "object") return block.text || block.content || "";
1130
+ return "";
1131
+ })
1132
+ .join("");
1133
+ }
1134
+ return String(value);
1135
+ }
1136
+
1137
+ /**
1138
+ * Normalize a request body for the Bedrock Converse API.
1139
+ *
1140
+ * Strips `cache_control` markers and flattens any array-shaped `system` /
1141
+ * message `content` (left behind by prompt-cache injection) back into the
1142
+ * plain strings the Converse API expects. Returns a shallow copy with a
1143
+ * normalized `messages` array; the original body is not mutated.
1144
+ *
1145
+ * @param {Object} body - Anthropic-format request body
1146
+ * @returns {Object} Body safe for Converse request construction
1147
+ */
1148
+ function normalizeBodyForConverse(body) {
1149
+ const normalized = { ...body };
1150
+
1151
+ if (normalized.system !== undefined) {
1152
+ normalized.system = flattenContentToText(normalized.system);
1153
+ }
1154
+
1155
+ if (Array.isArray(normalized.messages)) {
1156
+ normalized.messages = normalized.messages.map(msg => ({
1157
+ ...msg,
1158
+ content: flattenContentToText(msg.content),
1159
+ }));
1160
+ }
1161
+
1162
+ return normalized;
1163
+ }
1164
+
1107
1165
  async function invokeBedrock(body) {
1108
1166
  // 1. Validate Bearer token
1109
1167
  if (!config.bedrock?.apiKey) {
@@ -1130,7 +1188,10 @@ async function invokeBedrock(body) {
1130
1188
  }, "=== INJECTING STANDARD TOOLS (Bedrock) ===");
1131
1189
  }
1132
1190
 
1133
- const bedrockBody = { ...body, tools: toolsToSend };
1191
+ // Normalize away cache_control / array shapes that prompt-cache injection
1192
+ // may have applied: the Converse API expects plain-string system and
1193
+ // message content, not Anthropic cache_control blocks.
1194
+ const bedrockBody = { ...normalizeBodyForConverse(body), tools: toolsToSend };
1134
1195
 
1135
1196
  // 4. Detect model family and convert format
1136
1197
  const modelId = body._tierModel || config.bedrock.modelId;
@@ -1506,10 +1567,16 @@ async function invokeMoonshot(body) {
1506
1567
  "claude-haiku-4-5-20251001": "kimi-k2-turbo-preview",
1507
1568
  "claude-haiku-4-5": "kimi-k2-turbo-preview",
1508
1569
  "claude-3-haiku": "kimi-k2-turbo-preview",
1570
+ // moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
1571
+ // context-size pass fails on large tool-bearing payloads). Remap to a
1572
+ // fixed model that's broadly available on api.moonshot.ai.
1573
+ "moonshot-v1-auto": "moonshot-v1-128k",
1509
1574
  };
1510
1575
 
1511
1576
  const requestedModel = body._tierModel || body.model || config.moonshot.model;
1512
- const mappedModel = modelMap[requestedModel] || config.moonshot.model || "kimi-k2-turbo-preview";
1577
+ let mappedModel = modelMap[requestedModel] || config.moonshot.model || "kimi-k2-turbo-preview";
1578
+ // Guard against the deprecated auto model arriving via config too.
1579
+ if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
1513
1580
 
1514
1581
  // Convert messages using existing utility
1515
1582
  const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
@@ -1522,12 +1589,18 @@ async function invokeMoonshot(body) {
1522
1589
  messages.unshift({ role: "system", content: systemContent });
1523
1590
  }
1524
1591
 
1592
+ // kimi-k2.x (k2.5 / k2.6 …) are thinking models that only accept
1593
+ // temperature: 1 — any other value 400s with "invalid temperature".
1594
+ const isKimiThinking = /^kimi-k2/i.test(mappedModel);
1595
+
1525
1596
  const moonshotBody = {
1526
1597
  model: mappedModel,
1527
1598
  messages,
1528
1599
  max_tokens: body.max_tokens || 16384,
1529
- temperature: body.temperature ?? 0.7,
1530
- top_p: body.top_p ?? 1.0,
1600
+ // kimi-k2.x thinking models pin sampling params: temperature must be 1
1601
+ // and top_p must be 0.95 any other value 400s.
1602
+ temperature: isKimiThinking ? 1 : (body.temperature ?? 0.7),
1603
+ top_p: isKimiThinking ? 0.95 : (body.top_p ?? 1.0),
1531
1604
  stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
1532
1605
  };
1533
1606
 
@@ -2027,6 +2100,65 @@ async function invokeCodex(body) {
2027
2100
  };
2028
2101
  }
2029
2102
 
2103
+ /**
2104
+ * Compute request cost in USD from model pricing × token usage.
2105
+ * Registry returns per-1M-token prices ({ input, output }); returns null when
2106
+ * pricing is unknown so we don't record misleading zeros.
2107
+ */
2108
+ const _unknownCostWarned = new Set();
2109
+ function computeCostUsd(model, inputTokens, outputTokens) {
2110
+ try {
2111
+ const { getModelRegistrySync } = require("../routing/model-registry");
2112
+ const reg = getModelRegistrySync && getModelRegistrySync();
2113
+ const cost = reg?.getCost?.(model);
2114
+ if (!cost) return null;
2115
+ // Unknown model → record null (not a fabricated default), warn once so the
2116
+ // gap is visible and can be fixed via MODEL_PRICE_OVERRIDES.
2117
+ if (cost.unknown) {
2118
+ if (model && !_unknownCostWarned.has(model)) {
2119
+ _unknownCostWarned.add(model);
2120
+ logger.warn({ model }, "[Cost] No pricing for model — recording cost_usd=null. Set MODEL_PRICE_OVERRIDES to fix.");
2121
+ }
2122
+ return null;
2123
+ }
2124
+ if (cost.input == null && cost.output == null) return null;
2125
+ const inUsd = ((inputTokens || 0) / 1e6) * (cost.input || 0);
2126
+ const outUsd = ((outputTokens || 0) / 1e6) * (cost.output || 0);
2127
+ return Number((inUsd + outUsd).toFixed(6));
2128
+ } catch {
2129
+ return null;
2130
+ }
2131
+ }
2132
+
2133
+ // Telemetry prompt/response text is always captured (truncated) to build the
2134
+ // routing ML training corpus. Stored locally in .lynkr/telemetry.db only.
2135
+ const TELEMETRY_TEXT_MAXLEN = 2000;
2136
+
2137
+ /** Flatten the latest user message to plain text (for telemetry capture). */
2138
+ function captureRequestText(body) {
2139
+ const messages = body?.messages;
2140
+ if (!Array.isArray(messages)) return null;
2141
+ for (let i = messages.length - 1; i >= 0; i--) {
2142
+ const m = messages[i];
2143
+ if (m?.role !== "user") continue;
2144
+ let text = "";
2145
+ if (typeof m.content === "string") text = m.content;
2146
+ else if (Array.isArray(m.content)) {
2147
+ text = m.content.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ");
2148
+ }
2149
+ if (text) return text.slice(0, TELEMETRY_TEXT_MAXLEN);
2150
+ }
2151
+ return null;
2152
+ }
2153
+
2154
+ /** Flatten an Anthropic response's text blocks to plain text (for telemetry). */
2155
+ function captureResponseText(resultJson) {
2156
+ const content = resultJson?.content;
2157
+ if (!Array.isArray(content)) return null;
2158
+ const text = content.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ");
2159
+ return text ? text.slice(0, TELEMETRY_TEXT_MAXLEN) : null;
2160
+ }
2161
+
2030
2162
  async function invokeModel(body, options = {}) {
2031
2163
  const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
2032
2164
  const metricsCollector = getMetricsCollector();
@@ -2053,6 +2185,13 @@ async function invokeModel(body, options = {}) {
2053
2185
  const { injectPromptCaching } = require('./prompt-cache-injection');
2054
2186
  injectPromptCaching(body, initialProvider);
2055
2187
 
2188
+ // Always-on markdown formatting guard. Stops formatting-weak backends
2189
+ // (Moonshot/Kimi, Ollama, etc.) from emitting mangled ASCII box-drawing
2190
+ // "diagrams". Keyed off the routing-resolved provider/model; skipped for
2191
+ // Claude-family backends which already format cleanly.
2192
+ const { injectFormatGuard } = require('../context/output-format-guard');
2193
+ injectFormatGuard(body, { provider: initialProvider, model: tierSelectedModel });
2194
+
2056
2195
  // Build routing decision object for response headers
2057
2196
  const routingDecision = {
2058
2197
  provider: initialProvider,
@@ -2233,6 +2372,9 @@ async function invokeModel(body, options = {}) {
2233
2372
  circuit_breaker_state: breaker.state,
2234
2373
  quality_score: qualityScore,
2235
2374
  tokens_per_second: outputTokens && latency > 0 ? outputTokens / (latency / 1000) : null,
2375
+ cost_usd: computeCostUsd(routingDecision.model || body._tierModel, inputTokens, outputTokens),
2376
+ request_text: captureRequestText(body),
2377
+ response_text: captureResponseText(result.json),
2236
2378
  });
2237
2379
 
2238
2380
  // Return result with provider info and routing decision for headers
@@ -2249,6 +2391,71 @@ async function invokeModel(body, options = {}) {
2249
2391
  healthTracker.recordFailure(initialProvider, err, err.status);
2250
2392
  getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
2251
2393
 
2394
+ // Tier-aware escalate-then-demote fallback (TIER_FALLBACK_ENABLED).
2395
+ // On failure, try a MORE capable tier first (climb toward REASONING); only
2396
+ // if every higher tier is unavailable do we fall downward to SIMPLE/local.
2397
+ // Runs before the flat global fallback below and is never silent.
2398
+ if (config.tierFallback?.enabled && !options._tierFallbackInner && routingDecision.tier) {
2399
+ const { getFallbackChain } = require("../routing/tier-fallback");
2400
+ const chain = getFallbackChain(routingDecision.tier);
2401
+ for (const cand of chain) {
2402
+ try {
2403
+ logger.warn({
2404
+ fromTier: routingDecision.tier,
2405
+ fromProvider: initialProvider,
2406
+ toTier: cand.tier,
2407
+ toProvider: cand.provider,
2408
+ toModel: cand.model,
2409
+ direction: cand.direction,
2410
+ }, "[TierFallback] Primary tier failed — attempting tier fallback");
2411
+
2412
+ const attempt = await invokeModel(
2413
+ { ...body, _tierModel: cand.model },
2414
+ {
2415
+ forceProvider: cand.provider,
2416
+ _tierFallbackInner: true,
2417
+ disableFallback: true,
2418
+ _cascadeInner: true,
2419
+ workspace,
2420
+ tenantPolicy,
2421
+ }
2422
+ );
2423
+
2424
+ metricsCollector.recordFallbackAttempt(initialProvider, cand.provider, "tier_fallback");
2425
+ logger.warn({
2426
+ servedTier: cand.tier,
2427
+ servedProvider: cand.provider,
2428
+ fromTier: routingDecision.tier,
2429
+ direction: cand.direction,
2430
+ }, "[TierFallback] Served by tier fallback");
2431
+
2432
+ return {
2433
+ ...attempt,
2434
+ actualProvider: cand.provider,
2435
+ routingDecision: {
2436
+ ...routingDecision,
2437
+ provider: cand.provider,
2438
+ model: cand.model,
2439
+ servedTier: cand.tier,
2440
+ fromTier: routingDecision.tier,
2441
+ fallback: true,
2442
+ fallbackDirection: cand.direction,
2443
+ method: "tier_fallback",
2444
+ },
2445
+ };
2446
+ } catch (innerErr) {
2447
+ logger.warn(
2448
+ { toProvider: cand.provider, error: innerErr.message },
2449
+ "[TierFallback] Candidate failed, trying next"
2450
+ );
2451
+ }
2452
+ }
2453
+ logger.warn(
2454
+ { fromTier: routingDecision.tier },
2455
+ "[TierFallback] All tier candidates exhausted — falling through"
2456
+ );
2457
+ }
2458
+
2252
2459
  // Check if we should fallback (any provider can fall back, not just ollama)
2253
2460
  const shouldFallback =
2254
2461
  isFallbackEnabled() &&
@@ -2394,6 +2601,9 @@ async function invokeModel(body, options = {}) {
2394
2601
  { status_code: 200, output_tokens: fbOutputTokens, tool_calls_made: fbToolCalls, was_fallback: true, retry_count: 0, latency_ms: Date.now() - startTime }
2395
2602
  ),
2396
2603
  tokens_per_second: fbOutputTokens && fallbackLatency > 0 ? fbOutputTokens / (fallbackLatency / 1000) : null,
2604
+ cost_usd: computeCostUsd(routingDecision.model || body._tierModel, fbInputTokens, fbOutputTokens),
2605
+ request_text: captureRequestText(body),
2606
+ response_text: captureResponseText(fallbackResult.json),
2397
2607
  });
2398
2608
 
2399
2609
  // Return result with actual provider used (fallback provider) and routing decision
@@ -2502,4 +2712,5 @@ function destroyHttpAgents() {
2502
2712
  module.exports = {
2503
2713
  invokeModel,
2504
2714
  destroyHttpAgents,
2715
+ normalizeBodyForConverse,
2505
2716
  };
@@ -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,33 +147,24 @@ 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
- }
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);
155
+
156
+ // Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose
157
+ // content is an empty string with "Invalid request: tokenization failed".
158
+ // This happens when a turn had only non-text blocks (thinking / image /
159
+ // stripped content) and flattened to "". Replace empty/whitespace-only
160
+ // content with a single space — but never touch an assistant message that
161
+ // carries tool_calls, where content: null is intentional and required.
162
+ for (const m of converted) {
163
+ if (m.role === 'tool') continue;
164
+ const hasToolCalls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
165
+ if (hasToolCalls) continue;
166
+ if (typeof m.content !== 'string' || m.content.trim() === '') {
167
+ m.content = ' ';
176
168
  }
177
169
  }
178
170
 
@@ -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,6 +174,9 @@ 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;
132
180
  return injectAnthropicCacheBreakpoints(body);
133
181
  }
134
182
 
@@ -137,4 +185,5 @@ module.exports = {
137
185
  injectAnthropicCacheBreakpoints,
138
186
  injectGeminiCacheBreakpoints,
139
187
  needsCacheInjection,
188
+ modelSupportsCacheControl,
140
189
  };
@@ -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 };
@@ -208,6 +208,11 @@ const tokenBudgetWarning = Number.parseInt(process.env.TOKEN_BUDGET_WARNING ?? "
208
208
  const tokenBudgetMax = Number.parseInt(process.env.TOKEN_BUDGET_MAX ?? "180000", 10);
209
209
  const tokenBudgetEnforcement = process.env.TOKEN_BUDGET_ENFORCEMENT !== "false"; // default true
210
210
 
211
+ // Caveman terse-output injection (opt-in, off by default)
212
+ const cavemanEnabled = process.env.CAVEMAN_ENABLED === "true";
213
+ const cavemanLevel = (process.env.CAVEMAN_LEVEL ?? "lite").toLowerCase();
214
+
215
+
211
216
  // TOON payload compression (opt-in)
212
217
  const toonEnabled = process.env.TOON_ENABLED === "true"; // default false
213
218
  const toonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
@@ -513,6 +518,12 @@ const agentsDefaultModel = process.env.AGENTS_DEFAULT_MODEL ?? "haiku";
513
518
  const agentsMaxSteps = Number.parseInt(process.env.AGENTS_MAX_STEPS ?? "15", 10);
514
519
  const agentsTimeout = Number.parseInt(process.env.AGENTS_TIMEOUT ?? "120000", 10);
515
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
+
516
527
  // LLM Audit logging configuration
517
528
  const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
518
529
  const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
@@ -641,6 +652,10 @@ var config = {
641
652
  toolResultCompression: {
642
653
  enabled: true,
643
654
  },
655
+ caveman: {
656
+ enabled: cavemanEnabled,
657
+ level: cavemanLevel,
658
+ },
644
659
  server: {
645
660
  jsonLimit: process.env.REQUEST_JSON_LIMIT ?? "1gb",
646
661
  },
@@ -766,6 +781,23 @@ var config = {
766
781
  maxSteps: Number.isNaN(agentsMaxSteps) ? 15 : agentsMaxSteps,
767
782
  timeout: Number.isNaN(agentsTimeout) ? 120000 : agentsTimeout,
768
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
+ },
769
801
  tests: {
770
802
  defaultCommand: testDefaultCommand ? testDefaultCommand.trim() : null,
771
803
  defaultArgs: testDefaultArgs,