lynkr 9.7.3 → 9.9.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 (74) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/api/middleware/loop-guard.js +87 -0
  15. package/src/api/middleware/request-logging.js +5 -64
  16. package/src/api/middleware/session.js +0 -0
  17. package/src/api/openai-router.js +120 -101
  18. package/src/api/providers-handler.js +27 -2
  19. package/src/api/router.js +450 -125
  20. package/src/budget/index.js +2 -19
  21. package/src/cache/semantic.js +9 -0
  22. package/src/clients/databricks.js +455 -142
  23. package/src/clients/gpt-utils.js +11 -105
  24. package/src/clients/openai-format.js +10 -3
  25. package/src/clients/openrouter-utils.js +49 -24
  26. package/src/clients/prompt-cache-injection.js +1 -0
  27. package/src/clients/provider-capabilities.js +1 -1
  28. package/src/clients/responses-format.js +34 -3
  29. package/src/clients/routing.js +15 -0
  30. package/src/config/index.js +36 -2
  31. package/src/context/gcf.js +275 -0
  32. package/src/context/tool-result-compressor.js +51 -9
  33. package/src/dashboard/api.js +1 -0
  34. package/src/logger/index.js +14 -1
  35. package/src/memory/search.js +12 -40
  36. package/src/memory/tools.js +3 -24
  37. package/src/orchestrator/bypass.js +4 -2
  38. package/src/orchestrator/index.js +120 -85
  39. package/src/routing/affinity-store.js +194 -0
  40. package/src/routing/agentic-detector.js +36 -6
  41. package/src/routing/bandit.js +25 -6
  42. package/src/routing/calibration.js +212 -0
  43. package/src/routing/client-profiles.js +292 -0
  44. package/src/routing/complexity-analyzer.js +48 -11
  45. package/src/routing/deescalator.js +148 -0
  46. package/src/routing/degradation.js +109 -0
  47. package/src/routing/feedback.js +157 -0
  48. package/src/routing/index.js +897 -87
  49. package/src/routing/intent-score.js +339 -0
  50. package/src/routing/interaction.js +3 -0
  51. package/src/routing/knn-router.js +70 -21
  52. package/src/routing/model-registry.js +28 -7
  53. package/src/routing/model-tiers.js +25 -2
  54. package/src/routing/reward-pipeline.js +68 -2
  55. package/src/routing/risk-analyzer.js +30 -1
  56. package/src/routing/risk-classifier.js +6 -2
  57. package/src/routing/session-affinity.js +162 -34
  58. package/src/routing/telemetry.js +286 -13
  59. package/src/routing/verifier.js +267 -0
  60. package/src/server.js +66 -21
  61. package/src/sessions/cleanup.js +17 -0
  62. package/src/tools/index.js +1 -15
  63. package/src/tools/smart-selection.js +10 -0
  64. package/src/tools/web-client.js +3 -3
  65. package/.eslintrc.cjs +0 -12
  66. package/benchmark-configs/litellm_config.yaml +0 -86
  67. package/benchmark-configs/lynkr.env +0 -48
  68. package/benchmark-configs/portkey-config.json +0 -60
  69. package/benchmark-configs/portkey-docker.sh +0 -23
  70. package/benchmark-tier-routing.js +0 -449
  71. package/funding.json +0 -110
  72. package/src/api/middleware/validation.js +0 -261
  73. package/src/routing/drift-monitor.js +0 -113
  74. package/src/workers/helpers.js +0 -185
@@ -12,6 +12,7 @@ const systemPrompt = require("../prompts/system");
12
12
  const historyCompression = require("../context/compression");
13
13
  const tokenBudget = require("../context/budget");
14
14
  const { applyToonCompression } = require("../context/toon");
15
+ const { applyGcfCompression } = require("../context/gcf");
15
16
  const { classifyRequestType, selectToolsSmartly } = require("../tools/smart-selection");
16
17
  const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom");
17
18
  const { createAuditLogger } = require("../logger/audit-logger");
@@ -20,7 +21,6 @@ const { getShuttingDown } = require("../api/health");
20
21
  const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
21
22
  const { detectBypass, buildBypassResponse } = require("./bypass");
22
23
  const crypto = require("crypto");
23
- const { asyncClone, asyncTransform, getPoolStats } = require("../workers/helpers");
24
24
  const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
25
25
  const lazyLoader = require("../tools/lazy-loader");
26
26
  const { areSimilarToolCalls } = require("../clients/gpt-utils");
@@ -43,6 +43,8 @@ function getDestinationUrl(providerType) {
43
43
  return config.azureOpenAI?.endpoint ?? 'unknown';
44
44
  case 'openrouter':
45
45
  return config.openrouter?.endpoint ?? 'unknown';
46
+ case 'edenai':
47
+ return config.edenai?.endpoint ?? 'unknown';
46
48
  case 'openai':
47
49
  return 'https://api.openai.com/v1/chat/completions';
48
50
  case 'llamacpp':
@@ -235,10 +237,18 @@ function normaliseTools(tools) {
235
237
  function ensureAnthropicToolFormat(tools) {
236
238
  if (!Array.isArray(tools) || tools.length === 0) return undefined;
237
239
  return tools.map((tool) => {
240
+ // Shape-detect: unwrap OpenAI-format tools rather than fabricating
241
+ // "unnamed_tool" from their absent top-level fields.
242
+ if (tool?.type === "function" && tool.function) {
243
+ tool = {
244
+ name: tool.function.name,
245
+ description: tool.function.description,
246
+ input_schema: tool.function.parameters,
247
+ };
248
+ }
238
249
  // Ensure input_schema has required 'type' field
239
250
  let input_schema = tool.input_schema || { type: "object", properties: {} };
240
251
 
241
- // If input_schema exists but missing 'type', add it
242
252
  if (input_schema && !input_schema.type) {
243
253
  input_schema = { type: "object", ...input_schema };
244
254
  }
@@ -468,7 +478,6 @@ function injectToolLoopStopInstruction(messages, threshold = 5) {
468
478
  content: `⚠️ IMPORTANT: You have already executed ${toolResultCount} tool calls in this conversation. This is likely an infinite loop. STOP calling tools immediately and provide a direct text response to the user based on the information you have gathered. If you cannot complete the task, explain why. DO NOT call any more tools.`,
469
479
  };
470
480
 
471
- // Add to end of messages
472
481
  return [...messages, stopInstruction];
473
482
  }
474
483
 
@@ -550,7 +559,6 @@ function recordCrossRequestToolCall(session, toolCall) {
550
559
  }
551
560
 
552
561
  if (!mergedInto) {
553
- // New unique signature
554
562
  dedup.signatures[signature] = {
555
563
  count: 1,
556
564
  toolName,
@@ -610,7 +618,6 @@ function getMaxDedupCount(session) {
610
618
  function extractToolUseFromCurrentTurn(messages) {
611
619
  if (!Array.isArray(messages)) return [];
612
620
 
613
- // Find last user text message
614
621
  let lastUserTextIndex = -1;
615
622
  for (let i = messages.length - 1; i >= 0; i--) {
616
623
  const msg = messages[i];
@@ -731,7 +738,6 @@ function parseExecutionContent(content) {
731
738
  if (block.type === 'text' && typeof block.text === 'string') {
732
739
  return block.text;
733
740
  }
734
- // Handle other block types gracefully
735
741
  if (block.text) return block.text;
736
742
  if (block.content) return typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
737
743
  return null;
@@ -922,7 +928,6 @@ function normaliseToolChoice(choice) {
922
928
  function stripThinkingBlocks(text) {
923
929
  if (typeof text !== "string") return text;
924
930
 
925
- // Split into lines
926
931
  const lines = text.split("\n");
927
932
  const cleanedLines = [];
928
933
  let inThinkingBlock = false;
@@ -947,15 +952,12 @@ function stripThinkingBlocks(text) {
947
952
  continue;
948
953
  }
949
954
 
950
- // Reset empty line counter
951
955
  consecutiveEmptyLines = 0;
952
956
 
953
- // Skip lines that are part of thinking block
954
957
  if (inThinkingBlock) {
955
958
  continue;
956
959
  }
957
960
 
958
- // Keep this line
959
961
  cleanedLines.push(line);
960
962
  }
961
963
 
@@ -1318,8 +1320,8 @@ function sanitizePayload(payload) {
1318
1320
  if (Array.isArray(clean.tools) && clean.tools.length > 0) {
1319
1321
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1320
1322
  }
1321
- } else if (providerType === "openrouter") {
1322
- // OpenRouter supports tools - keep them as-is
1323
+ } else if (providerType === "openrouter" || providerType === "edenai") {
1324
+ // OpenRouter / Eden AI (OpenAI-compatible) support tools - keep them as-is.
1323
1325
  // Tools are already in Anthropic format and will be converted by openrouter-utils
1324
1326
  if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1325
1327
  delete clean.tools;
@@ -1330,7 +1332,6 @@ function sanitizePayload(payload) {
1330
1332
  if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1331
1333
  delete clean.tools;
1332
1334
  } else {
1333
- // Ensure tools are in Anthropic format
1334
1335
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1335
1336
  }
1336
1337
  } else if (providerType === "vertex") {
@@ -1348,6 +1349,15 @@ function sanitizePayload(payload) {
1348
1349
  } else {
1349
1350
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1350
1351
  }
1352
+ } else if (providerType === "azure-openai" || providerType === "openai") {
1353
+ // Azure OpenAI / OpenAI support tools — keep Anthropic format; the
1354
+ // client converts to Chat Completions / Responses format. Without this
1355
+ // branch the unknown-provider catch-all below deletes the tools.
1356
+ if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1357
+ delete clean.tools;
1358
+ } else {
1359
+ clean.tools = ensureAnthropicToolFormat(clean.tools);
1360
+ }
1351
1361
  } else if (Array.isArray(clean.tools)) {
1352
1362
  // Unknown provider - remove tools for safety
1353
1363
  delete clean.tools;
@@ -1431,9 +1441,14 @@ function sanitizePayload(payload) {
1431
1441
 
1432
1442
  // Optional TOON conversion for large JSON message payloads (prompt context only).
1433
1443
  // Run this BEFORE message coalescing to preserve parseable JSON boundaries.
1434
- applyToonCompression(clean, config.toon, { logger });
1444
+ // GCF takes precedence when enabled; otherwise TOON. Both are opt-in and mutually exclusive.
1445
+ if (config.gcf && config.gcf.enabled) {
1446
+ applyGcfCompression(clean, config.gcf, { logger });
1447
+ } else {
1448
+ applyToonCompression(clean, config.toon, { logger });
1449
+ }
1435
1450
 
1436
- // FIX: Handle consecutive messages with the same role (causes llama.cpp 400 error)
1451
+ // Handle consecutive messages with the same role (causes llama.cpp 400 error)
1437
1452
  // Strategy: Merge consecutive same-role messages, but NEVER merge messages
1438
1453
  // that contain tool_use or tool_result blocks — they must stay intact for
1439
1454
  // the provider's tool-call protocol.
@@ -1640,17 +1655,21 @@ async function runAgentLoop({
1640
1655
  "Agent loop step",
1641
1656
  );
1642
1657
 
1643
- // Trim messages when they grow too large to prevent OOM.
1644
- // Keep the first message (system/user) and the last MAX_LOOP_MESSAGES.
1645
- const MAX_LOOP_MESSAGES = 40;
1646
- if (cleanPayload.messages && cleanPayload.messages.length > MAX_LOOP_MESSAGES) {
1647
- const excess = cleanPayload.messages.length - MAX_LOOP_MESSAGES;
1648
- // Keep first 2 messages (system context + initial user) and trim from the middle
1649
- cleanPayload.messages.splice(2, excess);
1650
- logger.debug(
1651
- { trimmed: excess, remaining: cleanPayload.messages.length },
1652
- "Trimmed intermediate messages to prevent memory growth",
1653
- );
1658
+ // Trim over-long loop conversations to prevent OOM, keeping the head,
1659
+ // THE CURRENT TASK (latest user message with real typed text), and the
1660
+ // recent tail — a head+tail-only trim discards the ask itself when the
1661
+ // session opened with a greeting, and the model answers the greeting.
1662
+ // Tunable: LYNKR_MAX_LOOP_MESSAGES (default 40; 0 disables trimming
1663
+ // entirely). Disabling trades flat per-frame cost/latency for full
1664
+ // context fidelity — on weak models expect coherence loss on very long
1665
+ // loops (their competence degrades before their context window fills),
1666
+ // and on paid models expect per-frame input cost to grow with session
1667
+ // length. The task-preservation fix makes the default cap safe.
1668
+ const MAX_LOOP_MESSAGES = Number.isFinite(Number(process.env.LYNKR_MAX_LOOP_MESSAGES))
1669
+ ? Number(process.env.LYNKR_MAX_LOOP_MESSAGES)
1670
+ : 40;
1671
+ if (MAX_LOOP_MESSAGES > 0 && cleanPayload.messages && cleanPayload.messages.length > MAX_LOOP_MESSAGES) {
1672
+ cleanPayload.messages = trimLoopMessages(cleanPayload.messages, MAX_LOOP_MESSAGES);
1654
1673
  }
1655
1674
 
1656
1675
  // Debug: Log payload before sending to Azure
@@ -1717,7 +1736,6 @@ async function runAgentLoop({
1717
1736
  memoriesRetrieved: relevantMemories.length,
1718
1737
  }, 'Injecting long-term memories into context');
1719
1738
 
1720
- // Inject memories into system prompt
1721
1739
  const injectedSystem = memoryRetriever.injectMemoriesIntoSystem(
1722
1740
  cleanPayload.system,
1723
1741
  relevantMemories,
@@ -1924,9 +1942,9 @@ IMPORTANT TOOL USAGE RULES:
1924
1942
  // Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
1925
1943
  // analogous gate.
1926
1944
  const headroomProviderMap = {
1927
- 'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
1928
- 'openai': new Set(['azure-openai', 'openai', 'openrouter']),
1929
- 'google': new Set(['vertex', 'openrouter']),
1945
+ 'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter', 'edenai']),
1946
+ 'openai': new Set(['azure-openai', 'openai', 'openrouter', 'edenai']),
1947
+ 'google': new Set(['vertex', 'openrouter', 'edenai']),
1930
1948
  };
1931
1949
  const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
1932
1950
  const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
@@ -1985,7 +2003,6 @@ IMPORTANT TOOL USAGE RULES:
1985
2003
  // Generate correlation ID for request/response pairing
1986
2004
  const correlationId = `req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
1987
2005
 
1988
- // Log LLM request before invocation
1989
2006
  if (auditLogger.enabled) {
1990
2007
  auditLogger.logLlmRequest({
1991
2008
  correlationId,
@@ -2049,6 +2066,7 @@ IMPORTANT TOOL USAGE RULES:
2049
2066
  'bedrock',
2050
2067
  'vertex',
2051
2068
  'openrouter',
2069
+ 'edenai',
2052
2070
  'ollama',
2053
2071
  'openai',
2054
2072
  'azure-openai',
@@ -2107,7 +2125,6 @@ IMPORTANT TOOL USAGE RULES:
2107
2125
  throw modelError;
2108
2126
  }
2109
2127
 
2110
- // Extract and log actual token usage
2111
2128
  const actualUsage = databricksResponse.ok && config.tokenTracking?.enabled !== false
2112
2129
  ? tokens.extractUsageFromResponse(databricksResponse.json)
2113
2130
  : null;
@@ -2121,12 +2138,10 @@ IMPORTANT TOOL USAGE RULES:
2121
2138
  }
2122
2139
  }
2123
2140
 
2124
- // Log LLM response after invocation
2125
2141
  if (auditLogger.enabled) {
2126
2142
  const latencyMs = Date.now() - start;
2127
2143
 
2128
2144
  if (databricksResponse.stream) {
2129
- // Log streaming response (no content, just metadata)
2130
2145
  auditLogger.logLlmResponse({
2131
2146
  correlationId,
2132
2147
  sessionId: session?.id ?? null,
@@ -2139,7 +2154,6 @@ IMPORTANT TOOL USAGE RULES:
2139
2154
  streamingNote: 'Content streamed directly to client, not captured in audit log',
2140
2155
  });
2141
2156
  } else if (databricksResponse.ok && databricksResponse.json) {
2142
- // Log successful non-streaming response
2143
2157
  const message = databricksResponse.json;
2144
2158
  const assistantMessage = message.content ?? message.choices?.[0]?.message;
2145
2159
 
@@ -2158,7 +2172,6 @@ IMPORTANT TOOL USAGE RULES:
2158
2172
  status: databricksResponse.status,
2159
2173
  });
2160
2174
  } else {
2161
- // Log error response
2162
2175
  auditLogger.logLlmResponse({
2163
2176
  correlationId,
2164
2177
  sessionId: session?.id ?? null,
@@ -2333,7 +2346,11 @@ IMPORTANT TOOL USAGE RULES:
2333
2346
  ? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
2334
2347
  : (typeof message.content === "string" && message.content.trim().length > 0);
2335
2348
 
2336
- if (!hasTextContent && steps < settings.maxSteps - 1) {
2349
+ // The artifact redirect is an open-design (SERVER-mode) feature; in
2350
+ // client mode, drop the hallucinated calls and return whatever text
2351
+ // exists — never instruct the model to emit artifacts.
2352
+ const _clientOwnsOutput = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
2353
+ if (!hasTextContent && steps < settings.maxSteps - 1 && !_clientOwnsOutput) {
2337
2354
  logger.info({
2338
2355
  sessionId: session?.id ?? null,
2339
2356
  step: steps,
@@ -2362,7 +2379,6 @@ IMPORTANT TOOL USAGE RULES:
2362
2379
  const contentBlocks = [];
2363
2380
  let toolCallIdx = 0;
2364
2381
 
2365
- // Add text content if present
2366
2382
  if (message.content && typeof message.content === 'string' && message.content.trim()) {
2367
2383
  contentBlocks.push({
2368
2384
  type: "text",
@@ -2370,12 +2386,10 @@ IMPORTANT TOOL USAGE RULES:
2370
2386
  });
2371
2387
  }
2372
2388
 
2373
- // Add tool_use blocks from tool_calls
2374
2389
  for (const toolCall of toolCalls) {
2375
2390
  const func = toolCall.function || {};
2376
2391
  let input = {};
2377
2392
 
2378
- // Parse arguments string to object
2379
2393
  if (func.arguments) {
2380
2394
  try {
2381
2395
  input = typeof func.arguments === "string"
@@ -2448,15 +2462,18 @@ IMPORTANT TOOL USAGE RULES:
2448
2462
 
2449
2463
  cleanPayload.messages.push(assistantToolMessage);
2450
2464
 
2451
- // Check if tool execution should happen on client side
2452
2465
  const executionMode = config.toolExecutionMode || "server";
2453
2466
 
2454
- // IMPORTANT: Task tools (subagents) and Web Search tools ALWAYS execute server-side, regardless of execution mode to ensure reliability
2455
- // Separate Server-side tools from Client-side tools
2467
+ // Server-side tool split: in SERVER mode task/web tools run on Lynkr.
2468
+ // In CLIENT mode the client owns ALL its tools its "Task" (subagent
2469
+ // spawner) and WebSearch collide with Lynkr's same-named internal
2470
+ // tools, which have entirely different semantics.
2456
2471
  const serverSideToolCalls = [];
2457
2472
  const clientSideToolCalls = [];
2458
2473
 
2459
- const SERVER_SIDE_TOOLS = new Set(["task", "Task", "web_search", "web_fetch", "websearch", "webfetch", "web_agent", "WebSearch", "WebFetch", "WebAgent"]);
2474
+ const SERVER_SIDE_TOOLS = (executionMode === "passthrough" || executionMode === "client")
2475
+ ? new Set()
2476
+ : new Set(["task", "Task", "web_search", "web_fetch", "websearch", "webfetch", "web_agent", "WebSearch", "WebFetch", "WebAgent"]);
2460
2477
 
2461
2478
  for (const call of toolCalls) {
2462
2479
  const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
@@ -2608,7 +2625,6 @@ IMPORTANT TOOL USAGE RULES:
2608
2625
  }, "Executing multiple Task tools in parallel");
2609
2626
 
2610
2627
  try {
2611
- // Execute all Task tools in parallel
2612
2628
  const taskExecutions = await Promise.all(
2613
2629
  taskCalls.map(({ call }) => executeToolCall(call, {
2614
2630
  session,
@@ -2618,7 +2634,6 @@ IMPORTANT TOOL USAGE RULES:
2618
2634
  }))
2619
2635
  );
2620
2636
 
2621
- // Process results and add to messages
2622
2637
  taskExecutions.forEach((execution, index) => {
2623
2638
  const call = taskCalls[index].call;
2624
2639
  toolCallsExecuted += 1;
@@ -2782,7 +2797,6 @@ IMPORTANT TOOL USAGE RULES:
2782
2797
 
2783
2798
  cleanPayload.messages.push(toolResultMessage);
2784
2799
 
2785
- // Convert to Anthropic format for session storage
2786
2800
  let sessionToolResult;
2787
2801
  if (providerType === "azure-anthropic") {
2788
2802
  sessionToolResult = toolResultMessage.content;
@@ -2815,7 +2829,6 @@ IMPORTANT TOOL USAGE RULES:
2815
2829
 
2816
2830
  toolCallsExecuted += 1;
2817
2831
 
2818
- // Check if we've exceeded the max tool calls limit
2819
2832
  if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
2820
2833
  logger.error(
2821
2834
  {
@@ -2913,7 +2926,6 @@ IMPORTANT TOOL USAGE RULES:
2913
2926
 
2914
2927
  cleanPayload.messages.push(toolMessage);
2915
2928
 
2916
- // Convert to Anthropic format for session storage
2917
2929
  let sessionToolResultContent;
2918
2930
  if (providerType === "azure-anthropic") {
2919
2931
  // Azure Anthropic already has content in correct format
@@ -3009,7 +3021,6 @@ IMPORTANT TOOL USAGE RULES:
3009
3021
  `Tool call loop detected - same tool called ${loopThreshold} times with identical/similar parameters`,
3010
3022
  );
3011
3023
 
3012
- // Inject warning message to model
3013
3024
  loopWarningInjected = true;
3014
3025
  const warningMessage = {
3015
3026
  role: "user",
@@ -3107,7 +3118,7 @@ IMPORTANT TOOL USAGE RULES:
3107
3118
  if (Array.isArray(anthropicPayload?.content)) {
3108
3119
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3109
3120
  }
3110
- } else if (actualProvider === "openrouter") {
3121
+ } else if (actualProvider === "openrouter" || actualProvider === "edenai") {
3111
3122
  const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils");
3112
3123
 
3113
3124
  // Validate OpenRouter response has choices array before conversion
@@ -3246,7 +3257,6 @@ IMPORTANT TOOL USAGE RULES:
3246
3257
  };
3247
3258
  }
3248
3259
 
3249
- // Log OpenAI raw response
3250
3260
  logger.info({
3251
3261
  hasChoices: !!databricksResponse.json?.choices,
3252
3262
  choiceCount: databricksResponse.json?.choices?.length || 0,
@@ -3296,7 +3306,6 @@ IMPORTANT TOOL USAGE RULES:
3296
3306
  };
3297
3307
  }
3298
3308
 
3299
- // Log llama.cpp raw response
3300
3309
  logger.info({
3301
3310
  hasChoices: !!databricksResponse.json?.choices,
3302
3311
  choiceCount: databricksResponse.json?.choices?.length || 0,
@@ -3321,10 +3330,6 @@ IMPORTANT TOOL USAGE RULES:
3321
3330
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3322
3331
  } else if (actualProvider === "zai") {
3323
3332
  // Z.AI responses are already converted to Anthropic format in invokeZai
3324
- logger.info({
3325
- hasJson: !!databricksResponse.json,
3326
- jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 200),
3327
- }, "=== ZAI ORCHESTRATOR DEBUG ===");
3328
3333
  anthropicPayload = databricksResponse.json;
3329
3334
  if (Array.isArray(anthropicPayload?.content)) {
3330
3335
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
@@ -3337,10 +3342,6 @@ IMPORTANT TOOL USAGE RULES:
3337
3342
  }
3338
3343
  } else if (actualProvider === "moonshot") {
3339
3344
  // Moonshot responses are already converted to Anthropic format in invokeMoonshot
3340
- logger.info({
3341
- hasJson: !!databricksResponse.json,
3342
- jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 300),
3343
- }, "=== MOONSHOT ORCHESTRATOR DEBUG ===");
3344
3345
  anthropicPayload = databricksResponse.json;
3345
3346
  if (Array.isArray(anthropicPayload?.content)) {
3346
3347
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
@@ -3351,6 +3352,12 @@ IMPORTANT TOOL USAGE RULES:
3351
3352
  if (Array.isArray(anthropicPayload?.content)) {
3352
3353
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3353
3354
  }
3355
+ } else if (databricksResponse.json?.type === "message" && Array.isArray(databricksResponse.json?.content)) {
3356
+ // Shape-detected: already Anthropic (some clients convert upstream).
3357
+ // Re-converting via toAnthropicResponse reads the absent choices[]
3358
+ // and empties the content.
3359
+ anthropicPayload = databricksResponse.json;
3360
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3354
3361
  } else {
3355
3362
  anthropicPayload = toAnthropicResponse(
3356
3363
  databricksResponse.json,
@@ -3366,7 +3373,10 @@ IMPORTANT TOOL USAGE RULES:
3366
3373
  (item) => item.type === "text" && needsWebFallback(item.text),
3367
3374
  );
3368
3375
 
3369
- if (fallbackCandidate && !fallbackPerformed) {
3376
+ // Web fallback is SERVER-mode only: in client mode the client owns
3377
+ // WebSearch/WebFetch, and a server-side fetch would duplicate it.
3378
+ const _clientOwnsWeb = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
3379
+ if (fallbackCandidate && !fallbackPerformed && !_clientOwnsWeb) {
3370
3380
  if (providerType === "azure-anthropic") {
3371
3381
  anthropicPayload.content.push({
3372
3382
  type: "text",
@@ -3486,7 +3496,6 @@ IMPORTANT TOOL USAGE RULES:
3486
3496
 
3487
3497
  cleanPayload.messages.push(assistantToolMessage);
3488
3498
 
3489
- // Convert to Anthropic format for session storage
3490
3499
  let sessionFallbackContent;
3491
3500
  if (providerType === "azure-anthropic") {
3492
3501
  // Already in Anthropic format
@@ -3501,7 +3510,6 @@ IMPORTANT TOOL USAGE RULES:
3501
3510
  });
3502
3511
  }
3503
3512
 
3504
- // Add tool_use blocks from tool_calls
3505
3513
  if (Array.isArray(assistantToolMessage.tool_calls)) {
3506
3514
  for (const tc of assistantToolMessage.tool_calls) {
3507
3515
  const func = tc.function || {};
@@ -3555,7 +3563,6 @@ IMPORTANT TOOL USAGE RULES:
3555
3563
 
3556
3564
  cleanPayload.messages.push(toolResultMessage);
3557
3565
 
3558
- // Convert to Anthropic format for session storage
3559
3566
  let sessionFallbackToolResult;
3560
3567
  if (providerType === "azure-anthropic") {
3561
3568
  // Already in Anthropic format
@@ -3589,7 +3596,6 @@ IMPORTANT TOOL USAGE RULES:
3589
3596
 
3590
3597
  toolCallsExecuted += 1;
3591
3598
 
3592
- // Check if we've exceeded the max tool calls limit
3593
3599
  if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
3594
3600
  logger.error(
3595
3601
  {
@@ -3641,6 +3647,7 @@ IMPORTANT TOOL USAGE RULES:
3641
3647
  provider: databricksResponse.routingDecision.provider,
3642
3648
  model: databricksResponse.routingDecision.model,
3643
3649
  tier: databricksResponse.routingDecision.tier,
3650
+ score: databricksResponse.routingDecision.score ?? null,
3644
3651
  };
3645
3652
  }
3646
3653
 
@@ -3859,11 +3866,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3859
3866
 
3860
3867
  // === TOOL LOOP GUARD (EARLY CHECK) ===
3861
3868
  // Check BEFORE sanitization since sanitizePayload removes conversation history
3862
- // All providers use threshold 2 to catch loops early
3863
- const providerType = config.modelProvider?.type ?? "databricks";
3864
- const toolLoopThreshold = 2;
3865
- const { toolResultCount, toolUseCount } = countToolCallsInHistory(payload?.messages);
3866
-
3867
3869
  const executionMode = config.toolExecutionMode || "server";
3868
3870
  const isClientMode = executionMode === "client" || executionMode === "passthrough";
3869
3871
 
@@ -4009,7 +4011,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4009
4011
  const cleanPayload = sanitizePayload(payload);
4010
4012
  pTimer.mark("sanitizePayload");
4011
4013
 
4012
- // Proactively load tools based on prompt content (lazy loading)
4013
4014
  try {
4014
4015
  const { loaded } = lazyLoader.ensureToolsForPrompt(cleanPayload.messages);
4015
4016
  if (loaded.length > 0) {
@@ -4039,8 +4040,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4039
4040
  cacheKey = key;
4040
4041
  if (entry?.value) {
4041
4042
  try {
4042
- // Use worker pool for large cached responses
4043
- cachedResponse = await asyncClone(entry.value);
4043
+ cachedResponse = structuredClone(entry.value);
4044
4044
  } catch {
4045
4045
  cachedResponse = entry.value;
4046
4046
  }
@@ -4048,11 +4048,14 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4048
4048
  }
4049
4049
 
4050
4050
  if (cachedResponse) {
4051
- const anthropicPayload = toAnthropicResponse(
4052
- cachedResponse.json,
4053
- requestedModel,
4054
- wantsThinking,
4055
- );
4051
+ // Same shape guard as the live path: cached json may already be Anthropic.
4052
+ const anthropicPayload = (cachedResponse.json?.type === "message" && Array.isArray(cachedResponse.json?.content))
4053
+ ? cachedResponse.json
4054
+ : toAnthropicResponse(
4055
+ cachedResponse.json,
4056
+ requestedModel,
4057
+ wantsThinking,
4058
+ );
4056
4059
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
4057
4060
 
4058
4061
  const promptTokens = cachedResponse.json?.usage?.prompt_tokens ?? 0;
@@ -4069,6 +4072,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4069
4072
  provider: cachedResponse.routingDecision.provider,
4070
4073
  model: cachedResponse.routingDecision.model,
4071
4074
  tier: cachedResponse.routingDecision.tier,
4075
+ score: cachedResponse.routingDecision.score ?? null,
4072
4076
  };
4073
4077
  }
4074
4078
 
@@ -4095,7 +4099,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4095
4099
  };
4096
4100
  }
4097
4101
 
4098
- // Semantic cache lookup (fuzzy matching based on embedding similarity)
4099
4102
  let semanticLookupResult = null;
4100
4103
  const semanticCache = getSemanticCache();
4101
4104
  if (semanticCache.isEnabled()) {
@@ -4123,7 +4126,12 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4123
4126
 
4124
4127
  return {
4125
4128
  status: 200,
4126
- body: cachedBody,
4129
+ // Spread so the marker never mutates the stored cache entry.
4130
+ // Anthropic clients ignore unknown top-level fields; benchmarks
4131
+ // and dashboards use this to attribute cache hits honestly —
4132
+ // previously a hit was indistinguishable from a full-price call
4133
+ // in the response body (usage echoed the cached values).
4134
+ body: { ...cachedBody, lynkr_semantic_cache: { hit: true, similarity: semanticLookupResult.similarity ?? null } },
4127
4135
  terminationReason: "completion",
4128
4136
  };
4129
4137
  }
@@ -4132,9 +4140,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4132
4140
  }
4133
4141
  }
4134
4142
 
4135
- // NOTE: Tool loop guard moved to BEFORE sanitizePayload() since sanitization
4136
- // removes conversation history (consecutive same-role messages)
4137
-
4138
4143
  pTimer.mark("preAgentLoop");
4139
4144
  const loopResult = await runAgentLoop({
4140
4145
  cleanPayload,
@@ -4150,7 +4155,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4150
4155
  pTimer.mark("agentLoopDone");
4151
4156
  pTimer.done();
4152
4157
 
4153
- // Store successful responses in semantic cache for future fuzzy matching
4154
4158
  if (semanticCache.isEnabled() && semanticLookupResult && !semanticLookupResult.hit) {
4155
4159
  if (loopResult.response?.status === 200 && loopResult.response?.body) {
4156
4160
  try {
@@ -4164,8 +4168,39 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4164
4168
  return loopResult.response;
4165
4169
  }
4166
4170
 
4171
+ /**
4172
+ * Trim an over-long agent-loop conversation while ALWAYS preserving the
4173
+ * current task: the latest user message with real typed text. Keeps the
4174
+ * 2-message head (opening context), the task message (re-inserted if it
4175
+ * would fall in the trimmed middle), and the most recent tail. Boundary
4176
+ * orphans (tool_results whose tool_use was trimmed) are handled by the
4177
+ * converters' existing orphan-droppers.
4178
+ */
4179
+ function trimLoopMessages(messages, max) {
4180
+ const { extractCleanUserText } = require("../routing/intent-score");
4181
+ let taskIdx = -1;
4182
+ for (let i = messages.length - 1; i >= 2; i--) {
4183
+ if (messages[i]?.role === "user" && extractCleanUserText({ messages: [messages[i]] })) {
4184
+ taskIdx = i;
4185
+ break;
4186
+ }
4187
+ }
4188
+ const head = messages.slice(0, 2);
4189
+ const keepTail = max - head.length - 1;
4190
+ const tailStart = messages.length - keepTail;
4191
+ const taskMsg = taskIdx >= 2 && taskIdx < tailStart ? [messages[taskIdx]] : [];
4192
+ const trimmed = [...head, ...taskMsg, ...messages.slice(tailStart)];
4193
+ logger.debug(
4194
+ { trimmed: messages.length - trimmed.length, remaining: trimmed.length, taskKept: taskMsg.length > 0 || taskIdx >= tailStart || taskIdx < 2 },
4195
+ "Trimmed intermediate messages to prevent memory growth",
4196
+ );
4197
+ return trimmed;
4198
+ }
4199
+
4167
4200
  module.exports = {
4168
4201
  processMessage,
4169
4202
  // Exported for unit testing of response-metadata conversion.
4170
4203
  toAnthropicResponse,
4204
+ // Exported for unit testing of loop trimming (task-preservation contract).
4205
+ trimLoopMessages,
4171
4206
  };