lynkr 9.7.2 → 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 (80) 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/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. 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
 
@@ -1111,7 +1113,13 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
1111
1113
  id: openai.id ?? `msg_${Date.now()}`,
1112
1114
  type: "message",
1113
1115
  role: "assistant",
1114
- model: requestedModel,
1116
+ // Prefer the model the provider actually served with; fall back to the
1117
+ // requested model only when the provider omits it. Mirrors the direct
1118
+ // (non-tool) path at `databricksResponse.json.model || requestedModel`, so
1119
+ // tool-call responses no longer report a stale/aliased client-request model.
1120
+ model: (typeof openai?.model === "string" && openai.model.trim())
1121
+ ? openai.model
1122
+ : requestedModel,
1115
1123
  content: contentItems,
1116
1124
  stop_reason:
1117
1125
  choice?.finish_reason === "stop"
@@ -1123,8 +1131,11 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
1123
1131
  : choice?.finish_reason ?? "end_turn",
1124
1132
  stop_sequence: null,
1125
1133
  usage: {
1126
- input_tokens: usage.prompt_tokens ?? 0,
1127
- output_tokens: usage.completion_tokens ?? 0,
1134
+ // Accept both OpenAI (prompt_tokens/completion_tokens) and
1135
+ // already-Anthropic (input_tokens/output_tokens) usage shapes so token
1136
+ // counts survive regardless of which provider/converter produced them.
1137
+ input_tokens: usage.prompt_tokens ?? usage.input_tokens ?? 0,
1138
+ output_tokens: usage.completion_tokens ?? usage.output_tokens ?? 0,
1128
1139
  cache_creation_input_tokens: 0,
1129
1140
  cache_read_input_tokens: 0,
1130
1141
  },
@@ -1309,8 +1320,8 @@ function sanitizePayload(payload) {
1309
1320
  if (Array.isArray(clean.tools) && clean.tools.length > 0) {
1310
1321
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1311
1322
  }
1312
- } else if (providerType === "openrouter") {
1313
- // 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.
1314
1325
  // Tools are already in Anthropic format and will be converted by openrouter-utils
1315
1326
  if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1316
1327
  delete clean.tools;
@@ -1321,7 +1332,6 @@ function sanitizePayload(payload) {
1321
1332
  if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1322
1333
  delete clean.tools;
1323
1334
  } else {
1324
- // Ensure tools are in Anthropic format
1325
1335
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1326
1336
  }
1327
1337
  } else if (providerType === "vertex") {
@@ -1339,6 +1349,15 @@ function sanitizePayload(payload) {
1339
1349
  } else {
1340
1350
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1341
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
+ }
1342
1361
  } else if (Array.isArray(clean.tools)) {
1343
1362
  // Unknown provider - remove tools for safety
1344
1363
  delete clean.tools;
@@ -1422,9 +1441,14 @@ function sanitizePayload(payload) {
1422
1441
 
1423
1442
  // Optional TOON conversion for large JSON message payloads (prompt context only).
1424
1443
  // Run this BEFORE message coalescing to preserve parseable JSON boundaries.
1425
- 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
+ }
1426
1450
 
1427
- // 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)
1428
1452
  // Strategy: Merge consecutive same-role messages, but NEVER merge messages
1429
1453
  // that contain tool_use or tool_result blocks — they must stay intact for
1430
1454
  // the provider's tool-call protocol.
@@ -1631,17 +1655,21 @@ async function runAgentLoop({
1631
1655
  "Agent loop step",
1632
1656
  );
1633
1657
 
1634
- // Trim messages when they grow too large to prevent OOM.
1635
- // Keep the first message (system/user) and the last MAX_LOOP_MESSAGES.
1636
- const MAX_LOOP_MESSAGES = 40;
1637
- if (cleanPayload.messages && cleanPayload.messages.length > MAX_LOOP_MESSAGES) {
1638
- const excess = cleanPayload.messages.length - MAX_LOOP_MESSAGES;
1639
- // Keep first 2 messages (system context + initial user) and trim from the middle
1640
- cleanPayload.messages.splice(2, excess);
1641
- logger.debug(
1642
- { trimmed: excess, remaining: cleanPayload.messages.length },
1643
- "Trimmed intermediate messages to prevent memory growth",
1644
- );
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);
1645
1673
  }
1646
1674
 
1647
1675
  // Debug: Log payload before sending to Azure
@@ -1708,7 +1736,6 @@ async function runAgentLoop({
1708
1736
  memoriesRetrieved: relevantMemories.length,
1709
1737
  }, 'Injecting long-term memories into context');
1710
1738
 
1711
- // Inject memories into system prompt
1712
1739
  const injectedSystem = memoryRetriever.injectMemoriesIntoSystem(
1713
1740
  cleanPayload.system,
1714
1741
  relevantMemories,
@@ -1915,9 +1942,9 @@ IMPORTANT TOOL USAGE RULES:
1915
1942
  // Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
1916
1943
  // analogous gate.
1917
1944
  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']),
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']),
1921
1948
  };
1922
1949
  const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
1923
1950
  const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
@@ -1976,7 +2003,6 @@ IMPORTANT TOOL USAGE RULES:
1976
2003
  // Generate correlation ID for request/response pairing
1977
2004
  const correlationId = `req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
1978
2005
 
1979
- // Log LLM request before invocation
1980
2006
  if (auditLogger.enabled) {
1981
2007
  auditLogger.logLlmRequest({
1982
2008
  correlationId,
@@ -2040,6 +2066,7 @@ IMPORTANT TOOL USAGE RULES:
2040
2066
  'bedrock',
2041
2067
  'vertex',
2042
2068
  'openrouter',
2069
+ 'edenai',
2043
2070
  'ollama',
2044
2071
  'openai',
2045
2072
  'azure-openai',
@@ -2098,7 +2125,6 @@ IMPORTANT TOOL USAGE RULES:
2098
2125
  throw modelError;
2099
2126
  }
2100
2127
 
2101
- // Extract and log actual token usage
2102
2128
  const actualUsage = databricksResponse.ok && config.tokenTracking?.enabled !== false
2103
2129
  ? tokens.extractUsageFromResponse(databricksResponse.json)
2104
2130
  : null;
@@ -2112,12 +2138,10 @@ IMPORTANT TOOL USAGE RULES:
2112
2138
  }
2113
2139
  }
2114
2140
 
2115
- // Log LLM response after invocation
2116
2141
  if (auditLogger.enabled) {
2117
2142
  const latencyMs = Date.now() - start;
2118
2143
 
2119
2144
  if (databricksResponse.stream) {
2120
- // Log streaming response (no content, just metadata)
2121
2145
  auditLogger.logLlmResponse({
2122
2146
  correlationId,
2123
2147
  sessionId: session?.id ?? null,
@@ -2130,7 +2154,6 @@ IMPORTANT TOOL USAGE RULES:
2130
2154
  streamingNote: 'Content streamed directly to client, not captured in audit log',
2131
2155
  });
2132
2156
  } else if (databricksResponse.ok && databricksResponse.json) {
2133
- // Log successful non-streaming response
2134
2157
  const message = databricksResponse.json;
2135
2158
  const assistantMessage = message.content ?? message.choices?.[0]?.message;
2136
2159
 
@@ -2149,7 +2172,6 @@ IMPORTANT TOOL USAGE RULES:
2149
2172
  status: databricksResponse.status,
2150
2173
  });
2151
2174
  } else {
2152
- // Log error response
2153
2175
  auditLogger.logLlmResponse({
2154
2176
  correlationId,
2155
2177
  sessionId: session?.id ?? null,
@@ -2324,7 +2346,11 @@ IMPORTANT TOOL USAGE RULES:
2324
2346
  ? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
2325
2347
  : (typeof message.content === "string" && message.content.trim().length > 0);
2326
2348
 
2327
- 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) {
2328
2354
  logger.info({
2329
2355
  sessionId: session?.id ?? null,
2330
2356
  step: steps,
@@ -2353,7 +2379,6 @@ IMPORTANT TOOL USAGE RULES:
2353
2379
  const contentBlocks = [];
2354
2380
  let toolCallIdx = 0;
2355
2381
 
2356
- // Add text content if present
2357
2382
  if (message.content && typeof message.content === 'string' && message.content.trim()) {
2358
2383
  contentBlocks.push({
2359
2384
  type: "text",
@@ -2361,12 +2386,10 @@ IMPORTANT TOOL USAGE RULES:
2361
2386
  });
2362
2387
  }
2363
2388
 
2364
- // Add tool_use blocks from tool_calls
2365
2389
  for (const toolCall of toolCalls) {
2366
2390
  const func = toolCall.function || {};
2367
2391
  let input = {};
2368
2392
 
2369
- // Parse arguments string to object
2370
2393
  if (func.arguments) {
2371
2394
  try {
2372
2395
  input = typeof func.arguments === "string"
@@ -2439,15 +2462,18 @@ IMPORTANT TOOL USAGE RULES:
2439
2462
 
2440
2463
  cleanPayload.messages.push(assistantToolMessage);
2441
2464
 
2442
- // Check if tool execution should happen on client side
2443
2465
  const executionMode = config.toolExecutionMode || "server";
2444
2466
 
2445
- // IMPORTANT: Task tools (subagents) and Web Search tools ALWAYS execute server-side, regardless of execution mode to ensure reliability
2446
- // 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.
2447
2471
  const serverSideToolCalls = [];
2448
2472
  const clientSideToolCalls = [];
2449
2473
 
2450
- 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"]);
2451
2477
 
2452
2478
  for (const call of toolCalls) {
2453
2479
  const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
@@ -2599,7 +2625,6 @@ IMPORTANT TOOL USAGE RULES:
2599
2625
  }, "Executing multiple Task tools in parallel");
2600
2626
 
2601
2627
  try {
2602
- // Execute all Task tools in parallel
2603
2628
  const taskExecutions = await Promise.all(
2604
2629
  taskCalls.map(({ call }) => executeToolCall(call, {
2605
2630
  session,
@@ -2609,7 +2634,6 @@ IMPORTANT TOOL USAGE RULES:
2609
2634
  }))
2610
2635
  );
2611
2636
 
2612
- // Process results and add to messages
2613
2637
  taskExecutions.forEach((execution, index) => {
2614
2638
  const call = taskCalls[index].call;
2615
2639
  toolCallsExecuted += 1;
@@ -2773,7 +2797,6 @@ IMPORTANT TOOL USAGE RULES:
2773
2797
 
2774
2798
  cleanPayload.messages.push(toolResultMessage);
2775
2799
 
2776
- // Convert to Anthropic format for session storage
2777
2800
  let sessionToolResult;
2778
2801
  if (providerType === "azure-anthropic") {
2779
2802
  sessionToolResult = toolResultMessage.content;
@@ -2806,7 +2829,6 @@ IMPORTANT TOOL USAGE RULES:
2806
2829
 
2807
2830
  toolCallsExecuted += 1;
2808
2831
 
2809
- // Check if we've exceeded the max tool calls limit
2810
2832
  if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
2811
2833
  logger.error(
2812
2834
  {
@@ -2904,7 +2926,6 @@ IMPORTANT TOOL USAGE RULES:
2904
2926
 
2905
2927
  cleanPayload.messages.push(toolMessage);
2906
2928
 
2907
- // Convert to Anthropic format for session storage
2908
2929
  let sessionToolResultContent;
2909
2930
  if (providerType === "azure-anthropic") {
2910
2931
  // Azure Anthropic already has content in correct format
@@ -3000,7 +3021,6 @@ IMPORTANT TOOL USAGE RULES:
3000
3021
  `Tool call loop detected - same tool called ${loopThreshold} times with identical/similar parameters`,
3001
3022
  );
3002
3023
 
3003
- // Inject warning message to model
3004
3024
  loopWarningInjected = true;
3005
3025
  const warningMessage = {
3006
3026
  role: "user",
@@ -3098,7 +3118,7 @@ IMPORTANT TOOL USAGE RULES:
3098
3118
  if (Array.isArray(anthropicPayload?.content)) {
3099
3119
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3100
3120
  }
3101
- } else if (actualProvider === "openrouter") {
3121
+ } else if (actualProvider === "openrouter" || actualProvider === "edenai") {
3102
3122
  const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils");
3103
3123
 
3104
3124
  // Validate OpenRouter response has choices array before conversion
@@ -3237,7 +3257,6 @@ IMPORTANT TOOL USAGE RULES:
3237
3257
  };
3238
3258
  }
3239
3259
 
3240
- // Log OpenAI raw response
3241
3260
  logger.info({
3242
3261
  hasChoices: !!databricksResponse.json?.choices,
3243
3262
  choiceCount: databricksResponse.json?.choices?.length || 0,
@@ -3287,7 +3306,6 @@ IMPORTANT TOOL USAGE RULES:
3287
3306
  };
3288
3307
  }
3289
3308
 
3290
- // Log llama.cpp raw response
3291
3309
  logger.info({
3292
3310
  hasChoices: !!databricksResponse.json?.choices,
3293
3311
  choiceCount: databricksResponse.json?.choices?.length || 0,
@@ -3312,10 +3330,6 @@ IMPORTANT TOOL USAGE RULES:
3312
3330
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3313
3331
  } else if (actualProvider === "zai") {
3314
3332
  // Z.AI responses are already converted to Anthropic format in invokeZai
3315
- logger.info({
3316
- hasJson: !!databricksResponse.json,
3317
- jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 200),
3318
- }, "=== ZAI ORCHESTRATOR DEBUG ===");
3319
3333
  anthropicPayload = databricksResponse.json;
3320
3334
  if (Array.isArray(anthropicPayload?.content)) {
3321
3335
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
@@ -3328,10 +3342,6 @@ IMPORTANT TOOL USAGE RULES:
3328
3342
  }
3329
3343
  } else if (actualProvider === "moonshot") {
3330
3344
  // Moonshot responses are already converted to Anthropic format in invokeMoonshot
3331
- logger.info({
3332
- hasJson: !!databricksResponse.json,
3333
- jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 300),
3334
- }, "=== MOONSHOT ORCHESTRATOR DEBUG ===");
3335
3345
  anthropicPayload = databricksResponse.json;
3336
3346
  if (Array.isArray(anthropicPayload?.content)) {
3337
3347
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
@@ -3342,6 +3352,12 @@ IMPORTANT TOOL USAGE RULES:
3342
3352
  if (Array.isArray(anthropicPayload?.content)) {
3343
3353
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3344
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);
3345
3361
  } else {
3346
3362
  anthropicPayload = toAnthropicResponse(
3347
3363
  databricksResponse.json,
@@ -3357,7 +3373,10 @@ IMPORTANT TOOL USAGE RULES:
3357
3373
  (item) => item.type === "text" && needsWebFallback(item.text),
3358
3374
  );
3359
3375
 
3360
- 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) {
3361
3380
  if (providerType === "azure-anthropic") {
3362
3381
  anthropicPayload.content.push({
3363
3382
  type: "text",
@@ -3477,7 +3496,6 @@ IMPORTANT TOOL USAGE RULES:
3477
3496
 
3478
3497
  cleanPayload.messages.push(assistantToolMessage);
3479
3498
 
3480
- // Convert to Anthropic format for session storage
3481
3499
  let sessionFallbackContent;
3482
3500
  if (providerType === "azure-anthropic") {
3483
3501
  // Already in Anthropic format
@@ -3492,7 +3510,6 @@ IMPORTANT TOOL USAGE RULES:
3492
3510
  });
3493
3511
  }
3494
3512
 
3495
- // Add tool_use blocks from tool_calls
3496
3513
  if (Array.isArray(assistantToolMessage.tool_calls)) {
3497
3514
  for (const tc of assistantToolMessage.tool_calls) {
3498
3515
  const func = tc.function || {};
@@ -3546,7 +3563,6 @@ IMPORTANT TOOL USAGE RULES:
3546
3563
 
3547
3564
  cleanPayload.messages.push(toolResultMessage);
3548
3565
 
3549
- // Convert to Anthropic format for session storage
3550
3566
  let sessionFallbackToolResult;
3551
3567
  if (providerType === "azure-anthropic") {
3552
3568
  // Already in Anthropic format
@@ -3580,7 +3596,6 @@ IMPORTANT TOOL USAGE RULES:
3580
3596
 
3581
3597
  toolCallsExecuted += 1;
3582
3598
 
3583
- // Check if we've exceeded the max tool calls limit
3584
3599
  if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
3585
3600
  logger.error(
3586
3601
  {
@@ -3632,6 +3647,7 @@ IMPORTANT TOOL USAGE RULES:
3632
3647
  provider: databricksResponse.routingDecision.provider,
3633
3648
  model: databricksResponse.routingDecision.model,
3634
3649
  tier: databricksResponse.routingDecision.tier,
3650
+ score: databricksResponse.routingDecision.score ?? null,
3635
3651
  };
3636
3652
  }
3637
3653
 
@@ -3850,11 +3866,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3850
3866
 
3851
3867
  // === TOOL LOOP GUARD (EARLY CHECK) ===
3852
3868
  // Check BEFORE sanitization since sanitizePayload removes conversation history
3853
- // All providers use threshold 2 to catch loops early
3854
- const providerType = config.modelProvider?.type ?? "databricks";
3855
- const toolLoopThreshold = 2;
3856
- const { toolResultCount, toolUseCount } = countToolCallsInHistory(payload?.messages);
3857
-
3858
3869
  const executionMode = config.toolExecutionMode || "server";
3859
3870
  const isClientMode = executionMode === "client" || executionMode === "passthrough";
3860
3871
 
@@ -4000,7 +4011,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4000
4011
  const cleanPayload = sanitizePayload(payload);
4001
4012
  pTimer.mark("sanitizePayload");
4002
4013
 
4003
- // Proactively load tools based on prompt content (lazy loading)
4004
4014
  try {
4005
4015
  const { loaded } = lazyLoader.ensureToolsForPrompt(cleanPayload.messages);
4006
4016
  if (loaded.length > 0) {
@@ -4030,8 +4040,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4030
4040
  cacheKey = key;
4031
4041
  if (entry?.value) {
4032
4042
  try {
4033
- // Use worker pool for large cached responses
4034
- cachedResponse = await asyncClone(entry.value);
4043
+ cachedResponse = structuredClone(entry.value);
4035
4044
  } catch {
4036
4045
  cachedResponse = entry.value;
4037
4046
  }
@@ -4039,11 +4048,14 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4039
4048
  }
4040
4049
 
4041
4050
  if (cachedResponse) {
4042
- const anthropicPayload = toAnthropicResponse(
4043
- cachedResponse.json,
4044
- requestedModel,
4045
- wantsThinking,
4046
- );
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
+ );
4047
4059
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
4048
4060
 
4049
4061
  const promptTokens = cachedResponse.json?.usage?.prompt_tokens ?? 0;
@@ -4053,6 +4065,17 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4053
4065
  anthropicPayload.usage.cache_read_input_tokens = promptTokens;
4054
4066
  anthropicPayload.usage.cache_creation_input_tokens = 0;
4055
4067
 
4068
+ // Carry routing metadata on the cache-hit path too, so downstream model
4069
+ // name resolution (OpenClaw) behaves the same as the live loop path.
4070
+ if (cachedResponse.routingDecision) {
4071
+ anthropicPayload._routingMeta = {
4072
+ provider: cachedResponse.routingDecision.provider,
4073
+ model: cachedResponse.routingDecision.model,
4074
+ tier: cachedResponse.routingDecision.tier,
4075
+ score: cachedResponse.routingDecision.score ?? null,
4076
+ };
4077
+ }
4078
+
4056
4079
  appendTurnToSession(session, {
4057
4080
  role: "assistant",
4058
4081
  type: "message",
@@ -4076,7 +4099,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4076
4099
  };
4077
4100
  }
4078
4101
 
4079
- // Semantic cache lookup (fuzzy matching based on embedding similarity)
4080
4102
  let semanticLookupResult = null;
4081
4103
  const semanticCache = getSemanticCache();
4082
4104
  if (semanticCache.isEnabled()) {
@@ -4104,7 +4126,12 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4104
4126
 
4105
4127
  return {
4106
4128
  status: 200,
4107
- 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 } },
4108
4135
  terminationReason: "completion",
4109
4136
  };
4110
4137
  }
@@ -4113,9 +4140,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4113
4140
  }
4114
4141
  }
4115
4142
 
4116
- // NOTE: Tool loop guard moved to BEFORE sanitizePayload() since sanitization
4117
- // removes conversation history (consecutive same-role messages)
4118
-
4119
4143
  pTimer.mark("preAgentLoop");
4120
4144
  const loopResult = await runAgentLoop({
4121
4145
  cleanPayload,
@@ -4131,7 +4155,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4131
4155
  pTimer.mark("agentLoopDone");
4132
4156
  pTimer.done();
4133
4157
 
4134
- // Store successful responses in semantic cache for future fuzzy matching
4135
4158
  if (semanticCache.isEnabled() && semanticLookupResult && !semanticLookupResult.hit) {
4136
4159
  if (loopResult.response?.status === 200 && loopResult.response?.body) {
4137
4160
  try {
@@ -4145,6 +4168,39 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4145
4168
  return loopResult.response;
4146
4169
  }
4147
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
+
4148
4200
  module.exports = {
4149
4201
  processMessage,
4202
+ // Exported for unit testing of response-metadata conversion.
4203
+ toAnthropicResponse,
4204
+ // Exported for unit testing of loop trimming (task-preservation contract).
4205
+ trimLoopMessages,
4150
4206
  };