lynkr 9.9.1 → 9.11.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 (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. package/src/tools/workspace.js +0 -204
@@ -2,10 +2,8 @@ const config = require("../config");
2
2
  const { invokeModel } = require("../clients/databricks");
3
3
  const { appendTurnToSession } = require("../sessions/record");
4
4
  const { upsertSession } = require("../sessions/store");
5
- const { executeToolCall } = require("../tools");
6
5
  const policy = require("../policy");
7
6
  const logger = require("../logger");
8
- const { needsWebFallback } = require("../policy/web-fallback");
9
7
  const promptCache = require("../cache/prompt");
10
8
  const tokens = require("../utils/tokens");
11
9
  const systemPrompt = require("../prompts/system");
@@ -13,7 +11,6 @@ const historyCompression = require("../context/compression");
13
11
  const tokenBudget = require("../context/budget");
14
12
  const { applyToonCompression } = require("../context/toon");
15
13
  const { applyGcfCompression } = require("../context/gcf");
16
- const { classifyRequestType, selectToolsSmartly } = require("../tools/smart-selection");
17
14
  const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom");
18
15
  const { createAuditLogger } = require("../logger/audit-logger");
19
16
  const { getResolvedIp, runWithDnsContext } = require("../clients/dns-logger");
@@ -22,9 +19,9 @@ const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require
22
19
  const { detectBypass, buildBypassResponse } = require("./bypass");
23
20
  const crypto = require("crypto");
24
21
  const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
25
- const lazyLoader = require("../tools/lazy-loader");
26
22
  const { areSimilarToolCalls } = require("../clients/gpt-utils");
27
23
  const { getModelRegistrySync } = require("../routing/model-registry");
24
+ const sessionAffinity = require("../routing/session-affinity");
28
25
 
29
26
  /**
30
27
  * Get destination URL for audit logging based on provider type
@@ -359,49 +356,11 @@ function removeMatchingAssistantToolUse(cleanMessages, toolUseId) {
359
356
  }
360
357
  }
361
358
 
362
- const WEB_SEARCH_NORMALIZED = new Set(["websearch", "web_search", "web-search"]);
363
359
 
364
360
  function normaliseToolIdentifier(name = "") {
365
361
  return String(name).toLowerCase().replace(/[^a-z0-9]/g, "");
366
362
  }
367
363
 
368
- function buildWebSearchSummary(rawContent, options = {}) {
369
- if (rawContent === undefined || rawContent === null) return null;
370
- let data = rawContent;
371
- if (typeof data === "string") {
372
- const trimmed = data.trim();
373
- if (!trimmed) return null;
374
- try {
375
- data = JSON.parse(trimmed);
376
- } catch {
377
- return null;
378
- }
379
- }
380
- if (!data || typeof data !== "object") return null;
381
- const results = Array.isArray(data.results) ? data.results : [];
382
- if (results.length === 0) return null;
383
- const maxItems =
384
- Number.isInteger(options.maxItems) && options.maxItems > 0 ? options.maxItems : 5;
385
- const lines = [];
386
- for (let i = 0; i < results.length && lines.length < maxItems; i += 1) {
387
- const item = results[i];
388
- if (!item || typeof item !== "object") continue;
389
- const title = item.title || item.name || item.url || item.href;
390
- const url = item.url || item.href || "";
391
- const snippet = item.snippet || item.summary || item.excerpt || "";
392
- if (!title && !snippet) continue;
393
- let line = `${lines.length + 1}. ${title ?? snippet}`;
394
- if (snippet && snippet !== title) {
395
- line += ` — ${snippet}`;
396
- }
397
- if (url) {
398
- line += ` (${url})`;
399
- }
400
- lines.push(line);
401
- }
402
- if (lines.length === 0) return null;
403
- return `Top search hits:\n${lines.join("\n")}`;
404
- }
405
364
 
406
365
  /**
407
366
  * Count tool_use and tool_result blocks in message history.
@@ -475,7 +434,7 @@ function injectToolLoopStopInstruction(messages, threshold = 5) {
475
434
  // Inject instruction to stop tool calls and provide a final answer
476
435
  const stopInstruction = {
477
436
  role: "user",
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.`,
437
+ 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. Your response must contain your actual findings — NOT an acknowledgment or restatement of this warning.`,
479
438
  };
480
439
 
481
440
  return [...messages, stopInstruction];
@@ -494,6 +453,23 @@ const DEDUP_MAX_SIGNATURES = 50;
494
453
  const DEDUP_WARN_THRESHOLD = 5;
495
454
  const DEDUP_TERMINATE_THRESHOLD = 8;
496
455
 
456
+ /**
457
+ * State-management tools are called repeatedly with similar args BY DESIGN —
458
+ * that's their job, not a loop. Opencode calls `todowrite` after every step;
459
+ * Claude Code uses `TodoWrite`; OpenWorker uses `todo_write`/`propose_plan`.
460
+ * Counting them in the dedup tracker produced false-positive loop warnings
461
+ * and force-terminations on healthy agent sessions (live incident: an
462
+ * opencode analysis run was force-terminated into a raw tool-result dump
463
+ * because its todo updates hit the terminate threshold).
464
+ * @param {Object} toolCall - tool_use block
465
+ * @returns {boolean}
466
+ */
467
+ function isDedupExemptTool(toolCall) {
468
+ const raw = toolCall?.function?.name ?? toolCall?.name ?? '';
469
+ const name = String(raw).toLowerCase().replace(/[_-]/g, '');
470
+ return name.includes('todo') || name === 'updateplan' || name === 'proposeplan';
471
+ }
472
+
497
473
  /**
498
474
  * Initialise session.metadata.toolCallDedup if missing.
499
475
  * @param {Object} session
@@ -504,6 +480,7 @@ function ensureDedupStructure(session) {
504
480
  session.metadata.toolCallDedup = {
505
481
  signatures: {},
506
482
  similarGroups: {},
483
+ seenIds: {},
507
484
  lastResetAt: Date.now(),
508
485
  warningInjected: false,
509
486
  };
@@ -518,9 +495,26 @@ function ensureDedupStructure(session) {
518
495
  */
519
496
  function recordCrossRequestToolCall(session, toolCall) {
520
497
  if (!session?.metadata) return;
498
+ if (isDedupExemptTool(toolCall)) return; // repeat-by-design tools never count toward loop detection
521
499
  ensureDedupStructure(session);
522
500
 
523
501
  const dedup = session.metadata.toolCallDedup;
502
+
503
+ // Stateless HTTP agents (opencode, Cursor, OpenWorker) replay the FULL
504
+ // conversation history on every agent-loop step, so the same tool_use block
505
+ // arrives again on step 2, 3, 4… Counting each replay made counts grow
506
+ // quadratically with turn length — any exploration past ~5 steps hit the
507
+ // terminate threshold regardless of what it was doing (live incident:
508
+ // a 7-step opencode code-trace was force-terminated as a "loop").
509
+ // Each tool_use block carries a unique id: count each id exactly once.
510
+ const blockId = toolCall?.id;
511
+ if (blockId) {
512
+ if (!dedup.seenIds) dedup.seenIds = {};
513
+ if (dedup.seenIds[blockId]) return; // already counted this exact call
514
+ dedup.seenIds[blockId] = 1;
515
+ // Bounded: reset wipes this on each new user question; cap as a backstop.
516
+ if (Object.keys(dedup.seenIds).length > 500) dedup.seenIds = { [blockId]: 1 };
517
+ }
524
518
  const signature = getToolCallSignature(toolCall);
525
519
  const toolName = toolCall.function?.name ?? toolCall.name ?? 'unknown';
526
520
  const args = toolCall.function?.arguments ?? toolCall.input;
@@ -662,6 +656,7 @@ function resetDedupTracking(session) {
662
656
  session.metadata.toolCallDedup = {
663
657
  signatures: {},
664
658
  similarGroups: {},
659
+ seenIds: {},
665
660
  lastResetAt: Date.now(),
666
661
  warningInjected: false,
667
662
  };
@@ -710,205 +705,6 @@ function sanitiseAzureTools(tools) {
710
705
  return cleaned.size > 0 ? Array.from(cleaned.values()) : undefined;
711
706
  }
712
707
 
713
- function parseToolArguments(toolCall) {
714
- if (!toolCall?.function?.arguments) return {};
715
- const raw = toolCall.function.arguments;
716
- if (typeof raw !== "string") return raw ?? {};
717
- try {
718
- return JSON.parse(raw);
719
- } catch {
720
- return {};
721
- }
722
- }
723
-
724
- function parseExecutionContent(content) {
725
- if (content === undefined || content === null) {
726
- return null;
727
- }
728
- if (typeof content === "string") {
729
- const trimmed = content.trim();
730
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
731
- try {
732
- const parsed = JSON.parse(trimmed);
733
- // Handle Anthropic content blocks array - extract text
734
- if (Array.isArray(parsed)) {
735
- const textParts = parsed
736
- .filter(block => block && typeof block === 'object')
737
- .map(block => {
738
- if (block.type === 'text' && typeof block.text === 'string') {
739
- return block.text;
740
- }
741
- if (block.text) return block.text;
742
- if (block.content) return typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
743
- return null;
744
- })
745
- .filter(text => text !== null);
746
-
747
- if (textParts.length > 0) {
748
- return textParts.join('\n');
749
- }
750
- }
751
- return parsed;
752
- } catch {
753
- return content;
754
- }
755
- }
756
- return content;
757
- }
758
- // Handle content that's already an array (content blocks)
759
- if (Array.isArray(content)) {
760
- const textParts = content
761
- .filter(block => block && typeof block === 'object')
762
- .map(block => {
763
- if (block.type === 'text' && typeof block.text === 'string') {
764
- return block.text;
765
- }
766
- if (block.text) return block.text;
767
- if (block.content) return typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
768
- return null;
769
- })
770
- .filter(text => text !== null);
771
-
772
- if (textParts.length > 0) {
773
- return textParts.join('\n');
774
- }
775
- }
776
- return content;
777
- }
778
-
779
- function createFallbackAssistantMessage(providerType, { text, toolCall }) {
780
- if (providerType === "azure-anthropic") {
781
- const blocks = [];
782
- if (typeof text === "string" && text.trim().length > 0) {
783
- blocks.push({ type: "text", text: text.trim() });
784
- }
785
- blocks.push({
786
- type: "tool_use",
787
- id: toolCall.id ?? `tool_${Date.now()}`,
788
- name: toolCall.function?.name ?? "tool",
789
- input: parseToolArguments(toolCall),
790
- });
791
- return {
792
- role: "assistant",
793
- content: blocks,
794
- };
795
- }
796
- return {
797
- role: "assistant",
798
- content: text ?? "",
799
- tool_calls: [
800
- {
801
- id: toolCall.id,
802
- function: toolCall.function,
803
- },
804
- ],
805
- };
806
- }
807
-
808
- function createFallbackToolResultMessage(providerType, { toolCall, execution }) {
809
- const toolName = execution.name ?? toolCall.function?.name ?? "tool";
810
- const toolId = execution.id ?? toolCall.id ?? `tool_${Date.now()}`;
811
- if (providerType === "azure-anthropic") {
812
- const parsed = parseExecutionContent(execution.content);
813
- let contentBlocks;
814
- if (typeof parsed === "string" || parsed === null) {
815
- contentBlocks = [
816
- {
817
- type: "tool_result",
818
- tool_use_id: toolId,
819
- content: parsed ?? "",
820
- is_error: execution.ok === false,
821
- },
822
- ];
823
- } else {
824
- contentBlocks = [
825
- {
826
- type: "tool_result",
827
- tool_use_id: toolId,
828
- content: JSON.stringify(parsed),
829
- is_error: execution.ok === false,
830
- },
831
- ];
832
- }
833
- return {
834
- role: "user",
835
- content: contentBlocks,
836
- };
837
- }
838
- return {
839
- role: "tool",
840
- tool_call_id: toolId,
841
- name: toolCall.function?.name ?? toolName,
842
- content: execution.content,
843
- };
844
- }
845
-
846
- function extractWebSearchUrls(messages, options = {}, toolNameLookup = new Map()) {
847
- const max = Number.isInteger(options.max) && options.max > 0 ? options.max : 10;
848
- const urls = [];
849
- const seen = new Set();
850
- if (!Array.isArray(messages)) return urls;
851
-
852
- for (let i = messages.length - 1; i >= 0; i -= 1) {
853
- const message = messages[i];
854
- if (!message) continue;
855
- if (Array.isArray(message.content)) {
856
- for (const part of message.content) {
857
- if (!part || part.type !== "tool_result") continue;
858
- const toolIdentifier = toolNameLookup.get(part.tool_use_id ?? "") ?? null;
859
- if (!toolIdentifier || !WEB_SEARCH_NORMALIZED.has(toolIdentifier)) continue;
860
- let data = part.content;
861
- if (typeof data === "string") {
862
- try {
863
- data = JSON.parse(data);
864
- } catch {
865
- continue;
866
- }
867
- }
868
- if (!data || typeof data !== "object") continue;
869
- const results = Array.isArray(data.results) ? data.results : [];
870
- for (const entry of results) {
871
- if (!entry || typeof entry !== "object") continue;
872
- const url = entry.url ?? entry.href ?? null;
873
- if (!url) continue;
874
- if (seen.has(url)) continue;
875
- seen.add(url);
876
- urls.push(url);
877
- if (urls.length >= max) return urls;
878
- }
879
- }
880
- continue;
881
- }
882
-
883
- if (message.role === "tool") {
884
- const toolIdentifier = normaliseToolIdentifier(message.name ?? "");
885
- if (!WEB_SEARCH_NORMALIZED.has(toolIdentifier)) continue;
886
- let data = message.content;
887
- if (typeof data === "string") {
888
- try {
889
- data = JSON.parse(data);
890
- } catch {
891
- continue;
892
- }
893
- }
894
- if (!data || typeof data !== "object") continue;
895
- const results = Array.isArray(data.results) ? data.results : [];
896
- for (const entry of results) {
897
- if (!entry || typeof entry !== "object") continue;
898
- const url = entry.url ?? entry.href ?? null;
899
- if (!url) continue;
900
- if (seen.has(url)) continue;
901
- seen.add(url);
902
- urls.push(url);
903
- if (urls.length >= max) return urls;
904
- }
905
- continue;
906
- }
907
- }
908
-
909
- return urls;
910
- }
911
-
912
708
  function normaliseToolChoice(choice) {
913
709
  if (!choice) return undefined;
914
710
  if (typeof choice === "string") return choice; // "auto", "none"
@@ -1381,34 +1177,13 @@ function sanitizePayload(payload) {
1381
1177
  delete clean.tool_choice;
1382
1178
  }
1383
1179
 
1384
- // Smart tool selection (server mode only). In client/passthrough mode the
1385
- // client (e.g. Claude Code) owns tool execution, so stripping its tools would
1386
- // make the model emit calls for tools we removed — they then get dropped as
1387
- // "hallucinated" and the session makes no progress. Pass tools through intact.
1388
- const inClientMode = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
1389
- if (!inClientMode && config.smartToolSelection?.enabled && Array.isArray(clean.tools) && clean.tools.length > 0) {
1390
- const classification = classifyRequestType(clean);
1391
- const selectedTools = selectToolsSmartly(clean.tools, classification, {
1392
- provider: providerType,
1393
- tokenBudget: config.smartToolSelection.tokenBudget,
1394
- config: config.smartToolSelection
1395
- });
1396
-
1397
- // Only log if tools were actually filtered (avoid logging overhead)
1398
- if (selectedTools.length !== clean.tools.length) {
1399
- logger.info({
1400
- requestType: classification.type,
1401
- originalCount: clean.tools.length,
1402
- selectedCount: selectedTools.length,
1403
- provider: providerType
1404
- }, "Smart tool selection applied");
1405
- }
1406
-
1407
- clean.tools = selectedTools.length > 0 ? selectedTools : undefined;
1408
- }
1180
+ // The client owns tool execution — its tools always pass through intact.
1181
+ // Stripping any would make the model emit calls for tools we removed; they
1182
+ // then get dropped as "hallucinated" and the session makes no progress.
1409
1183
 
1410
- // Always false: the agent loop needs buffered JSON to parse tool calls.
1411
- // Lynkr synthesises SSE back to the client from the buffered response.
1184
+ // Default false: the buffered path parses tool calls from complete JSON
1185
+ // and Lynkr synthesises SSE back to the client. runAgentLoop flips this to
1186
+ // true per-step when the Phase-2b stream transform is active.
1412
1187
  clean.stream = false;
1413
1188
 
1414
1189
  if (
@@ -1504,8 +1279,10 @@ function sanitizePayload(payload) {
1504
1279
  return clean;
1505
1280
  }
1506
1281
 
1282
+ // maxSteps default is 2, not 1: the loop no longer executes tools (the client
1283
+ // owns them), but hallucination recovery still needs one re-prompt iteration.
1507
1284
  const DEFAULT_LOOP_OPTIONS = {
1508
- maxSteps: config.policy.maxStepsPerTurn ?? 6,
1285
+ maxSteps: config.policy.maxStepsPerTurn ?? 2,
1509
1286
  maxDurationMs: 120000,
1510
1287
  maxToolCallsPerRequest: config.policy.maxToolCallsPerRequest ?? 20, // Prevent runaway tool calling
1511
1288
  };
@@ -1605,11 +1382,6 @@ async function runAgentLoop({
1605
1382
  const auditLogger = createAuditLogger(config.audit);
1606
1383
  const start = Date.now();
1607
1384
  let steps = 0;
1608
- let toolCallsExecuted = 0;
1609
- let fallbackPerformed = false;
1610
- const toolCallNames = new Map();
1611
- const toolCallHistory = new Map(); // Track tool calls to detect loops: signature -> count
1612
- let loopWarningInjected = false; // Track if we've already warned about loops
1613
1385
 
1614
1386
  while (steps < settings.maxSteps) {
1615
1387
  if (Date.now() - start > settings.maxDurationMs) {
@@ -1622,7 +1394,6 @@ async function runAgentLoop({
1622
1394
  {
1623
1395
  sessionId: session?.id ?? null,
1624
1396
  steps,
1625
- toolCallsExecuted,
1626
1397
  durationMs: Date.now() - start,
1627
1398
  },
1628
1399
  "Agent loop interrupted - system shutting down",
@@ -1687,6 +1458,30 @@ async function runAgentLoop({
1687
1458
 
1688
1459
 
1689
1460
  if (steps === 1 && agentTimer) agentTimer.mark("preCompression");
1461
+
1462
+ // === CONVERSATION DISTILLATION (TencentDB-inspired L0-L3 pipeline) ===
1463
+ // Long conversations collapse older turns into one distilled block
1464
+ // (persona + scenario summary) before history compression runs.
1465
+ if (steps === 1 && config.memory?.enabled !== false && config.memory?.distillation?.enabled !== false) {
1466
+ try {
1467
+ const distiller = require('../memory/distiller');
1468
+ if (distiller.needsDistillation(cleanPayload.messages)) {
1469
+ const result = distiller.distillMessages(cleanPayload.messages, {
1470
+ sessionId: session?.id,
1471
+ });
1472
+ if (result.applied) {
1473
+ cleanPayload.messages = result.messages;
1474
+ logger.debug({
1475
+ sessionId: session?.id ?? null,
1476
+ ...result.stats,
1477
+ }, '[distiller] Conversation distillation applied');
1478
+ }
1479
+ }
1480
+ } catch (err) {
1481
+ logger.warn({ err, sessionId: session?.id }, 'Distillation failed, continuing with full history');
1482
+ }
1483
+ }
1484
+
1690
1485
  if (steps === 1 && config.historyCompression?.enabled !== false) {
1691
1486
  try {
1692
1487
  if (historyCompression.needsCompression(cleanPayload.messages)) {
@@ -2087,6 +1882,17 @@ IMPORTANT TOOL USAGE RULES:
2087
1882
  logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)');
2088
1883
  }
2089
1884
 
1885
+ // Phase 2b — cross-format streaming (opt-in via LYNKR_STREAM_TRANSFORM).
1886
+ // When the client wants a stream and the upstream speaks OpenAI SSE,
1887
+ // request a streamed upstream response and reshape it in flight instead of
1888
+ // buffering. Only on the first step: a hallucination-recovery re-prompt
1889
+ // needs a parsed, buffered response.
1890
+ const sseTransform = require("./sse-transformer");
1891
+ const _streamProvider = cleanPayload._forceProvider || providerType;
1892
+ const _wantsTransformStream =
1893
+ steps === 1 && sseTransform.shouldTransform(options?.clientWantsStream, _streamProvider);
1894
+ cleanPayload.stream = _wantsTransformStream;
1895
+
2090
1896
  if (agentTimer) agentTimer.mark("preInvokeModel");
2091
1897
  let databricksResponse;
2092
1898
  // Honor a body-level forceProvider marker (set by the OAuth tier-routing
@@ -2138,6 +1944,28 @@ IMPORTANT TOOL USAGE RULES:
2138
1944
  }
2139
1945
  }
2140
1946
 
1947
+ // Cache-aware routing (Phase 1): persist the session's warm-prefix state
1948
+ // from the response's cache counters so the router can price a mid-session
1949
+ // model switch against the live cache clock. Best-effort — never blocks
1950
+ // the response path.
1951
+ if (session?.id && actualUsage) {
1952
+ try {
1953
+ // Prefer the ROUTED provider/model over the request-level default:
1954
+ // the tier router inside invokeModel may have diverged from
1955
+ // providerType, and the cache lives with whoever actually served
1956
+ // (verified live: databricks default label on ollama-served turns).
1957
+ const served = databricksResponse.routingDecision || {};
1958
+ sessionAffinity.recordCacheUsage(session.id, {
1959
+ provider: served.provider || providerType,
1960
+ model: served.model || cleanPayload.model,
1961
+ cacheReadTokens: actualUsage.cacheReadTokens,
1962
+ cacheCreationTokens: actualUsage.cacheCreationTokens,
1963
+ });
1964
+ } catch (err) {
1965
+ logger.debug({ err: err.message }, "[Orchestrator] cache-state update failed");
1966
+ }
1967
+ }
1968
+
2141
1969
  if (auditLogger.enabled) {
2142
1970
  const latencyMs = Date.now() - start;
2143
1971
 
@@ -2186,15 +2014,76 @@ IMPORTANT TOOL USAGE RULES:
2186
2014
  }
2187
2015
  }
2188
2016
 
2189
- // Handle streaming responses (pass through without buffering)
2190
- if (databricksResponse.stream) {
2017
+ // Handle streaming responses (pass through without buffering). An
2018
+ // ok:false "stream" is a provider error whose body was already consumed
2019
+ // for logging — let it fall through to the error branches below instead
2020
+ // of synthesizing an empty completion from a spent stream.
2021
+ if (databricksResponse.stream && databricksResponse.ok !== false) {
2191
2022
  logger.debug(
2192
2023
  {
2193
2024
  sessionId: session?.id ?? null,
2194
2025
  status: databricksResponse.status,
2026
+ transform: _wantsTransformStream,
2195
2027
  },
2196
2028
  "Streaming response received, passing through"
2197
2029
  );
2030
+
2031
+ // Phase 2b: reshape OpenAI SSE into Anthropic SSE in flight. All
2032
+ // telemetry moves to the onClose finalizer — tool names/arg sizes are
2033
+ // accumulated DURING the stream, usage arrives with the final chunks.
2034
+ if (_wantsTransformStream) {
2035
+ const streamStartedAt = Date.now();
2036
+ const routingDecision = databricksResponse.routingDecision || {};
2037
+ const transformed = sseTransform.openaiToAnthropicSSE(databricksResponse.stream, {
2038
+ model: requestedModel,
2039
+ badgeText: config.routing?.visibleInteraction ? options?.streamBadgeText || null : null,
2040
+ onClose: (stats) => {
2041
+ try {
2042
+ const telemetry = require("../routing/telemetry");
2043
+ telemetry.record({
2044
+ // request_id is NOT NULL in the telemetry schema — a null id
2045
+ // silently drops the whole row.
2046
+ request_id: options?.correlationId || crypto.randomUUID(),
2047
+ session_id: session?.id ?? null,
2048
+ timestamp: streamStartedAt,
2049
+ tier: routingDecision.tier ?? null,
2050
+ provider: (routingDecision.provider || _streamProvider) + "",
2051
+ model: routingDecision.model ?? cleanPayload.model ?? null,
2052
+ routing_method: "stream-transform",
2053
+ status_code: stats.stopReason === "stream_error" ? 599 : 200,
2054
+ latency_ms: Date.now() - streamStartedAt,
2055
+ input_tokens: stats.usage.input_tokens,
2056
+ output_tokens: stats.usage.output_tokens,
2057
+ message_count: cleanPayload.messages?.length ?? null,
2058
+ tool_count: Array.isArray(cleanPayload.tools) ? cleanPayload.tools.length : 0,
2059
+ tool_calls_made: stats.toolCalls.length,
2060
+ error_type: stats.stopReason === "stream_error" ? "stream_error" : null,
2061
+ was_fallback: false,
2062
+ });
2063
+ logger.info({
2064
+ provider: routingDecision.provider || _streamProvider,
2065
+ stopReason: stats.stopReason,
2066
+ toolCalls: stats.toolCalls.map((t) => t.name),
2067
+ outputTokens: stats.usage.output_tokens,
2068
+ }, "[SSETransform] Stream closed");
2069
+ } catch (err) {
2070
+ logger.debug({ err: err.message }, "[SSETransform] Telemetry finalizer failed (non-fatal)");
2071
+ }
2072
+ },
2073
+ });
2074
+ return {
2075
+ response: {
2076
+ status: databricksResponse.status,
2077
+ headers: { "Content-Type": "text/event-stream" },
2078
+ stream: transformed,
2079
+ terminationReason: "streaming",
2080
+ },
2081
+ steps,
2082
+ durationMs: Date.now() - start,
2083
+ terminationReason: "streaming",
2084
+ };
2085
+ }
2086
+
2198
2087
  return {
2199
2088
  response: buildStreamingResponse(databricksResponse),
2200
2089
  steps,
@@ -2346,11 +2235,7 @@ IMPORTANT TOOL USAGE RULES:
2346
2235
  ? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
2347
2236
  : (typeof message.content === "string" && message.content.trim().length > 0);
2348
2237
 
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) {
2238
+ if (!hasTextContent && steps < settings.maxSteps) {
2354
2239
  logger.info({
2355
2240
  sessionId: session?.id ?? null,
2356
2241
  step: steps,
@@ -2429,663 +2314,38 @@ IMPORTANT TOOL USAGE RULES:
2429
2314
  },
2430
2315
  });
2431
2316
 
2432
- let assistantToolMessage;
2433
- if (providerType === "azure-anthropic" || isAnthropicFormat) {
2434
- // For Anthropic-format responses (azure-anthropic, Ollama native API,
2435
- // azure-openai Responses API), use the content array directly —
2436
- // it already contains both text and tool_use blocks in the correct format
2437
- assistantToolMessage = {
2438
- role: "assistant",
2439
- content: databricksResponse.json?.content ?? [],
2440
- };
2441
- } else {
2442
- assistantToolMessage = {
2443
- role: "assistant",
2444
- content: message.content ?? "",
2445
- tool_calls: message.tool_calls,
2446
- };
2447
- }
2448
-
2449
- // Only add fallback content for OpenAI-format responses (Anthropic format already has content)
2450
- if (
2451
- providerType !== "azure-anthropic" && !isAnthropicFormat &&
2452
- (!assistantToolMessage.content ||
2453
- (typeof assistantToolMessage.content === "string" &&
2454
- assistantToolMessage.content.trim().length === 0)) &&
2455
- toolCalls.length > 0
2456
- ) {
2457
- const toolNames = toolCalls
2458
- .map((call) => call.function?.name ?? "tool")
2459
- .join(", ");
2460
- assistantToolMessage.content = `Invoking tool(s): ${toolNames}`;
2461
- }
2462
-
2463
- cleanPayload.messages.push(assistantToolMessage);
2464
-
2465
- const executionMode = config.toolExecutionMode || "server";
2466
-
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.
2471
- const serverSideToolCalls = [];
2472
- const clientSideToolCalls = [];
2473
-
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"]);
2477
-
2478
- for (const call of toolCalls) {
2479
- const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
2480
- if (SERVER_SIDE_TOOLS.has(toolName)) {
2481
- serverSideToolCalls.push(call);
2482
- } else {
2483
- clientSideToolCalls.push(call);
2484
- }
2485
- }
2486
-
2487
- // If in passthrough/client mode and there are client-side tools, return them to client
2488
- // Server-side tools (Task, Web) will be executed below
2489
- if ((executionMode === "passthrough" || executionMode === "client") && clientSideToolCalls.length > 0) {
2490
- logger.debug(
2491
- {
2492
- sessionId: session?.id ?? null,
2493
- totalToolCount: toolCalls.length,
2494
- serverToolCount: serverSideToolCalls.length,
2495
- clientToolCount: clientSideToolCalls.length,
2496
- executionMode,
2497
- clientTools: clientSideToolCalls.map((c) => c.function?.name ?? c.name),
2498
- },
2499
- clientSideToolCalls.length > 1
2500
- ? `Parallel tool passthrough: ${clientSideToolCalls.length} tools → client`
2501
- : "Hybrid mode: returning non-Task tools to client, executing Task tools on server"
2502
- );
2503
-
2504
- // Filter sessionContent to only include client-side tool_use blocks
2505
- const clientContent = sessionContent.filter(block => {
2506
- if (block.type !== "tool_use") return true; // Keep text blocks
2507
- const toolName = (block.name ?? "").toLowerCase();
2508
- return !SERVER_SIDE_TOOLS.has(toolName); // Keep client-side tool_use blocks
2509
- });
2510
-
2511
- // Convert OpenRouter response to Anthropic format for CLI
2512
- const anthropicResponse = {
2513
- id: databricksResponse.json?.id || `msg_${Date.now()}`,
2514
- type: "message",
2515
- role: "assistant",
2516
- content: clientContent,
2517
- model: databricksResponse.json?.model || cleanPayload.model,
2518
- stop_reason: "tool_use",
2519
- usage: databricksResponse.json?.usage || {
2520
- input_tokens: 0,
2521
- output_tokens: 0,
2522
- },
2523
- };
2524
-
2525
- logger.debug(
2526
- {
2527
- sessionId: session?.id ?? null,
2528
- clientContentLength: clientContent.length,
2529
- clientContentTypes: clientContent.map(b => b.type),
2530
- },
2531
- "Passthrough: returning client-side tools to client"
2532
- );
2533
-
2534
- // If there are server-side tools, we need to execute them server-side first
2535
- // then continue the conversation loop. For now, let's fall through to execute server-side tools.
2536
- if (serverSideToolCalls.length === 0) {
2537
- // No server-side tools - pure passthrough
2538
- // Do NOT record outbound tool calls here — the inbound recording
2539
- // on the next request (when the client sends results back) is
2540
- // enough to detect real loops. Recording both outbound + inbound
2541
- // for the same call double-counts and triggers the dedup warning
2542
- // on the very first normal tool round-trip.
2543
-
2544
- return {
2545
- response: {
2546
- status: 200,
2547
- body: anthropicResponse,
2548
- terminationReason: "tool_use",
2549
- },
2550
- steps,
2551
- durationMs: Date.now() - start,
2552
- terminationReason: "tool_use",
2553
- };
2554
- }
2555
-
2556
- // Has Server-side tools - we need to execute them and continue
2557
- // Override toolCalls to only include Server-side tools for server execution
2558
- toolCalls = serverSideToolCalls;
2559
-
2560
- logger.debug(
2561
- {
2562
- sessionId: session?.id ?? null,
2563
- serverToolCount: serverSideToolCalls.length,
2564
- },
2565
- "Executing server-side tools in hybrid mode"
2566
- );
2567
- } else if (executionMode === "passthrough" || executionMode === "client") {
2568
- // Only Server-side tools, no Client-side tools - execute all server-side
2569
- logger.debug(
2570
- {
2571
- sessionId: session?.id ?? null,
2572
- serverToolCount: serverSideToolCalls.length,
2573
- },
2574
- "All tools are server-side tools - executing server-side"
2575
- );
2576
- }
2577
-
2578
- logger.debug(
2579
- {
2580
- sessionId: session?.id ?? null,
2581
- toolCount: toolCalls.length,
2582
- executionMode,
2317
+ // The client owns every tool: forward the tool_use turn untouched and
2318
+ // end this request — the client executes the tools and sends the
2319
+ // results back as a fresh request.
2320
+ //
2321
+ // Do NOT record outbound tool calls here — the inbound recording on
2322
+ // the next request (when the client sends results back) is enough to
2323
+ // detect real loops. Recording both outbound + inbound for the same
2324
+ // call double-counts and triggers the dedup warning on the very first
2325
+ // normal tool round-trip.
2326
+ const anthropicResponse = {
2327
+ id: databricksResponse.json?.id || `msg_${Date.now()}`,
2328
+ type: "message",
2329
+ role: "assistant",
2330
+ content: sessionContent,
2331
+ model: databricksResponse.json?.model || cleanPayload.model,
2332
+ stop_reason: "tool_use",
2333
+ usage: databricksResponse.json?.usage || {
2334
+ input_tokens: 0,
2335
+ output_tokens: 0,
2583
2336
  },
2584
- "Server mode: executing tools on server"
2585
- );
2586
-
2587
- // Evaluate policy for all tools first (must be sequential for rate limiting)
2588
- const toolCallsWithPolicy = [];
2589
- for (const call of toolCalls) {
2590
- const callId =
2591
- call.id ??
2592
- `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2593
- if (!call.id) {
2594
- call.id = callId;
2595
- }
2596
- toolCallNames.set(
2597
- callId,
2598
- normaliseToolIdentifier(call.function?.name ?? call.name ?? "tool"),
2599
- );
2600
- const decision = policy.evaluateToolCall({
2601
- call,
2602
- toolCallsExecuted: toolCallsExecuted + toolCallsWithPolicy.length,
2603
- });
2604
- toolCallsWithPolicy.push({ call, decision });
2605
- }
2606
-
2607
- // Identify Task tool calls for parallel execution
2608
- const taskCalls = [];
2609
- const nonTaskCalls = [];
2610
-
2611
- for (const item of toolCallsWithPolicy) {
2612
- const toolName = (item.call.function?.name ?? item.call.name ?? "").toLowerCase();
2613
- if (toolName === "task" && item.decision.allowed) {
2614
- taskCalls.push(item);
2615
- } else {
2616
- nonTaskCalls.push(item);
2617
- }
2618
- }
2619
-
2620
- // Execute Task tools in parallel if multiple exist
2621
- if (taskCalls.length > 1) {
2622
- logger.info({
2623
- taskCount: taskCalls.length,
2624
- sessionId: session?.id
2625
- }, "Executing multiple Task tools in parallel");
2626
-
2627
- try {
2628
- const taskExecutions = await Promise.all(
2629
- taskCalls.map(({ call }) => executeToolCall(call, {
2630
- session,
2631
- cwd,
2632
- requestMessages: cleanPayload.messages,
2633
- provider: providerType, // Pass provider for GPT-specific formatting
2634
- }))
2635
- );
2636
-
2637
- taskExecutions.forEach((execution, index) => {
2638
- const call = taskCalls[index].call;
2639
- toolCallsExecuted += 1;
2640
-
2641
- let toolMessage;
2642
- if (providerType === "azure-anthropic") {
2643
- const parsedContent = parseExecutionContent(execution.content);
2644
- const serialisedContent =
2645
- typeof parsedContent === "string" || parsedContent === null
2646
- ? parsedContent ?? ""
2647
- : JSON.stringify(parsedContent);
2648
-
2649
- toolMessage = {
2650
- role: "user",
2651
- content: [
2652
- {
2653
- type: "tool_result",
2654
- tool_use_id: call.id ?? execution.id,
2655
- content: serialisedContent,
2656
- is_error: execution.ok === false,
2657
- },
2658
- ],
2659
- };
2660
-
2661
- toolCallNames.set(
2662
- call.id ?? execution.id,
2663
- normaliseToolIdentifier(
2664
- call.function?.name ?? call.name ?? execution.name ?? "tool",
2665
- ),
2666
- );
2667
- } else {
2668
- // OpenAI format: tool_call_id MUST match the id from assistant's tool_call
2669
- toolMessage = {
2670
- role: "tool",
2671
- tool_call_id: call.id ?? execution.id,
2672
- name: call.function?.name ?? call.name ?? execution.name,
2673
- content: execution.content,
2674
- };
2675
- }
2676
-
2677
- cleanPayload.messages.push(toolMessage);
2678
-
2679
- // Convert to Anthropic format for session storage
2680
- let sessionToolResultContent;
2681
- if (providerType === "azure-anthropic") {
2682
- sessionToolResultContent = toolMessage.content;
2683
- } else {
2684
- sessionToolResultContent = [
2685
- {
2686
- type: "tool_result",
2687
- tool_use_id: toolMessage.tool_call_id,
2688
- content: toolMessage.content,
2689
- is_error: execution.ok === false,
2690
- },
2691
- ];
2692
- }
2693
-
2694
- appendTurnToSession(session, {
2695
- role: "tool",
2696
- type: "tool_result",
2697
- status: execution.status,
2698
- content: sessionToolResultContent,
2699
- metadata: {
2700
- tool: execution.name,
2701
- ok: execution.ok,
2702
- parallel: true,
2703
- parallelIndex: index,
2704
- totalParallel: taskExecutions.length
2705
- },
2706
- });
2707
- });
2708
-
2709
- logger.info({
2710
- completedTasks: taskExecutions.length,
2711
- sessionId: session?.id
2712
- }, "Completed parallel Task execution");
2713
-
2714
- // Check if we've exceeded the max tool calls limit after parallel execution
2715
- if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
2716
- logger.error(
2717
- {
2718
- sessionId: session?.id ?? null,
2719
- toolCallsExecuted,
2720
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
2721
- steps,
2722
- },
2723
- "Maximum tool calls per request exceeded after parallel Task execution - terminating",
2724
- );
2725
-
2726
- return {
2727
- response: {
2728
- status: 500,
2729
- body: {
2730
- error: {
2731
- type: "max_tool_calls_exceeded",
2732
- message: `Maximum tool calls per request exceeded. The model attempted to execute ${toolCallsExecuted} tool calls, but the limit is ${settings.maxToolCallsPerRequest}. This may indicate a complex task that requires breaking down into smaller steps.`,
2733
- },
2734
- },
2735
- terminationReason: "max_tool_calls_exceeded",
2736
- },
2737
- steps,
2738
- durationMs: Date.now() - start,
2739
- terminationReason: "max_tool_calls_exceeded",
2740
- };
2741
- }
2742
- } catch (error) {
2743
- logger.error({
2744
- error: error.message,
2745
- taskCount: taskCalls.length
2746
- }, "Error in parallel Task execution");
2747
-
2748
- // Fall back to sequential execution on error
2749
- taskCalls.forEach(item => nonTaskCalls.push(item));
2750
- }
2751
- } else if (taskCalls.length === 1) {
2752
- // Single Task tool - add back to non-task calls for normal processing
2753
- nonTaskCalls.push(...taskCalls);
2754
- }
2755
-
2756
- // Now process results (sequential for non-Task tools or blocked tools)
2757
- for (const { call, decision } of nonTaskCalls) {
2758
-
2759
- if (!decision.allowed) {
2760
- policy.logPolicyDecision(decision, {
2761
- sessionId: session?.id ?? null,
2762
- toolCall: call,
2763
- });
2764
-
2765
- const denialContent = JSON.stringify(
2766
- {
2767
- error: decision.code ?? "tool_blocked",
2768
- message: decision.reason ?? "Tool invocation blocked by policy.",
2769
- },
2770
- null,
2771
- 2,
2772
- );
2773
-
2774
- let toolResultMessage;
2775
- if (providerType === "azure-anthropic") {
2776
- // Anthropic format: tool_result in user message content array
2777
- toolResultMessage = {
2778
- role: "user",
2779
- content: [
2780
- {
2781
- type: "tool_result",
2782
- tool_use_id: call.id ?? `${call.function?.name ?? "tool"}_${Date.now()}`,
2783
- content: denialContent,
2784
- is_error: true,
2785
- },
2786
- ],
2787
- };
2788
- } else {
2789
- // OpenAI format
2790
- toolResultMessage = {
2791
- role: "tool",
2792
- tool_call_id: call.id ?? `${call.function?.name ?? "tool"}_${Date.now()}`,
2793
- name: call.function?.name ?? call.name,
2794
- content: denialContent,
2795
- };
2796
- }
2797
-
2798
- cleanPayload.messages.push(toolResultMessage);
2799
-
2800
- let sessionToolResult;
2801
- if (providerType === "azure-anthropic") {
2802
- sessionToolResult = toolResultMessage.content;
2803
- } else {
2804
- // Convert OpenRouter tool message to Anthropic format
2805
- sessionToolResult = [
2806
- {
2807
- type: "tool_result",
2808
- tool_use_id: toolResultMessage.tool_call_id,
2809
- content: toolResultMessage.content,
2810
- is_error: true,
2811
- },
2812
- ];
2813
- }
2814
-
2815
- appendTurnToSession(session, {
2816
- role: "tool",
2817
- type: "tool_result",
2818
- status: decision.status ?? 403,
2819
- content: sessionToolResult,
2820
- metadata: {
2821
- tool: toolResultMessage.name,
2822
- ok: false,
2823
- blocked: true,
2824
- reason: decision.reason ?? "Policy violation",
2825
- },
2826
- });
2827
- continue;
2828
- }
2829
-
2830
- toolCallsExecuted += 1;
2831
-
2832
- if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
2833
- logger.error(
2834
- {
2835
- sessionId: session?.id ?? null,
2836
- toolCallsExecuted,
2837
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
2838
- steps,
2839
- },
2840
- "Maximum tool calls per request exceeded - terminating",
2841
- );
2842
-
2843
- return {
2844
- response: {
2845
- status: 500,
2846
- body: {
2847
- error: {
2848
- type: "max_tool_calls_exceeded",
2849
- message: `Maximum tool calls per request exceeded. The model attempted to execute ${toolCallsExecuted} tool calls, but the limit is ${settings.maxToolCallsPerRequest}. This may indicate a complex task that requires breaking down into smaller steps.`,
2850
- },
2851
- },
2852
- terminationReason: "max_tool_calls_exceeded",
2853
- },
2854
- steps,
2855
- durationMs: Date.now() - start,
2856
- terminationReason: "max_tool_calls_exceeded",
2857
- };
2858
- }
2859
-
2860
- const execution = await executeToolCall(call, {
2861
- session,
2862
- cwd,
2863
- requestMessages: cleanPayload.messages,
2864
- provider: providerType, // Pass provider for GPT-specific formatting
2865
- });
2866
-
2867
- let toolMessage;
2868
- if (providerType === "azure-anthropic" || isAnthropicFormat) {
2869
- // Anthropic-format tool result for providers whose responses use
2870
- // Anthropic tool_use blocks (azure-anthropic, Ollama native API,
2871
- // azure-openai Responses API)
2872
- const parsedContent = parseExecutionContent(execution.content);
2873
- const serialisedContent =
2874
- typeof parsedContent === "string" || parsedContent === null
2875
- ? parsedContent ?? ""
2876
- : JSON.stringify(parsedContent);
2877
- let contentForToolResult = serialisedContent;
2878
- if (execution.ok) {
2879
- const toolIdentifier = normaliseToolIdentifier(
2880
- call.function?.name ?? call.name ?? execution.name ?? "tool",
2881
- );
2882
- if (WEB_SEARCH_NORMALIZED.has(toolIdentifier)) {
2883
- const summary = buildWebSearchSummary(parsedContent, {
2884
- maxItems: options?.webSearchSummaryLimit ?? 5,
2885
- });
2886
- if (summary) {
2887
- try {
2888
- const structured =
2889
- typeof parsedContent === "object" && parsedContent !== null
2890
- ? { ...parsedContent, summary }
2891
- : { raw: serialisedContent, summary };
2892
- contentForToolResult = JSON.stringify(structured, null, 2);
2893
- } catch {
2894
- contentForToolResult = `${serialisedContent}\n\nSummary:\n${summary}`;
2895
- }
2896
- }
2897
- }
2898
- }
2899
- toolMessage = {
2900
- role: "user",
2901
- content: [
2902
- {
2903
- type: "tool_result",
2904
- tool_use_id: call.id ?? execution.id,
2905
- content: contentForToolResult,
2906
- is_error: execution.ok === false,
2907
- },
2908
- ],
2909
- };
2910
- toolCallNames.set(
2911
- call.id ?? execution.id,
2912
- normaliseToolIdentifier(
2913
- call.function?.name ?? call.name ?? execution.name ?? "tool",
2914
- ),
2915
- );
2916
-
2917
- } else {
2918
- // OpenAI format: tool_call_id MUST match the id from assistant's tool_call
2919
- toolMessage = {
2920
- role: "tool",
2921
- tool_call_id: call.id ?? execution.id,
2922
- name: call.function?.name ?? call.name ?? execution.name,
2923
- content: execution.content,
2924
- };
2925
- }
2926
-
2927
- cleanPayload.messages.push(toolMessage);
2928
-
2929
- let sessionToolResultContent;
2930
- if (providerType === "azure-anthropic") {
2931
- // Azure Anthropic already has content in correct format
2932
- sessionToolResultContent = toolMessage.content;
2933
- } else {
2934
- // Convert OpenRouter tool message to Anthropic format
2935
- sessionToolResultContent = [
2936
- {
2937
- type: "tool_result",
2938
- tool_use_id: toolMessage.tool_call_id,
2939
- content: toolMessage.content,
2940
- is_error: execution.ok === false,
2941
- },
2942
- ];
2943
- }
2944
-
2945
- appendTurnToSession(session, {
2946
- role: "tool",
2947
- type: "tool_result",
2948
- status: execution.status,
2949
- content: sessionToolResultContent,
2950
- metadata: {
2951
- tool: execution.name,
2952
- ok: execution.ok,
2953
- registered: execution.metadata?.registered ?? null,
2954
- },
2955
- });
2956
-
2957
- if (execution.ok) {
2958
- logger.debug(
2959
- {
2960
- sessionId: session?.id ?? null,
2961
- tool: execution.name,
2962
- toolCallId: execution.id,
2963
- },
2964
- "Tool executed successfully",
2965
- );
2966
- } else {
2967
- logger.warn(
2968
- {
2969
- sessionId: session?.id ?? null,
2970
- tool: execution.name,
2971
- toolCallId: execution.id,
2972
- status: execution.status,
2973
- },
2974
- "Tool execution returned an error response",
2975
- );
2976
- }
2977
- }
2978
-
2979
- // === TOOL CALL LOOP DETECTION ===
2980
- // Track tool calls to detect infinite loops where the model calls the same tool
2981
- // repeatedly with identical or similar parameters
2982
- // All providers use threshold 2 and similarity-based detection
2983
- const loopThreshold = 2;
2984
-
2985
- for (const call of toolCalls) {
2986
- const signature = getToolCallSignature(call);
2987
- const existingEntry = toolCallHistory.get(signature);
2988
- let count = (existingEntry?.count || 0) + 1;
2989
- toolCallHistory.set(signature, { count, call });
2990
-
2991
- const toolName = call.function?.name ?? call.name ?? 'unknown';
2992
-
2993
- // Check for similar (not just identical) tool calls across all providers
2994
- // This catches cases where the model slightly varies parameters but is essentially looping
2995
- for (const [existingSig, existingData] of toolCallHistory.entries()) {
2996
- if (existingSig !== signature && areSimilarToolCalls(call, existingData.call)) {
2997
- // Found a similar call - increase count to trigger loop detection earlier
2998
- count = Math.max(count, existingData.count + 1);
2999
- logger.debug({
3000
- tool: toolName,
3001
- currentSignature: signature,
3002
- similarSignature: existingSig,
3003
- combinedCount: count,
3004
- }, "Similar tool call detected - combining counts");
3005
- }
3006
- }
3007
-
3008
- if (count === loopThreshold && !loopWarningInjected) {
3009
- logger.warn(
3010
- {
3011
- sessionId: session?.id ?? null,
3012
- correlationId: options?.correlationId,
3013
- tool: toolName,
3014
- loopCount: count,
3015
- loopThreshold,
3016
- signature: signature,
3017
- action: 'warning_injected',
3018
- totalSteps: steps,
3019
- remainingSteps: settings.maxSteps - steps,
3020
- },
3021
- `Tool call loop detected - same tool called ${loopThreshold} times with identical/similar parameters`,
3022
- );
3023
-
3024
- loopWarningInjected = true;
3025
- const warningMessage = {
3026
- role: "user",
3027
- content: `⚠️ CRITICAL SYSTEM WARNING: You have called the "${toolName}" tool ${count} times with identical or similar parameters. This IS an infinite loop. STOP calling this tool immediately. You MUST now provide a direct text response to the user based on the results you have received. If the tool returned "no results" or empty output, that IS the final answer - do not retry. Summarize your findings and respond.`,
3028
- };
3029
-
3030
- cleanPayload.messages.push(warningMessage);
3031
-
3032
- if (session) {
3033
- appendTurnToSession(session, {
3034
- role: "user",
3035
- type: "system_warning",
3036
- status: 200,
3037
- content: warningMessage.content,
3038
- metadata: {
3039
- reason: "tool_call_loop_warning",
3040
- toolName,
3041
- loopCount: count,
3042
- loopThreshold,
3043
- },
3044
- });
3045
- }
3046
- } else if (count > loopThreshold) {
3047
- // Force termination after threshold exceeded
3048
- // Log FULL context for debugging why the loop occurred
3049
- logger.error(
3050
- {
3051
- sessionId: session?.id ?? null,
3052
- correlationId: options?.correlationId,
3053
- tool: toolName,
3054
- loopCount: count,
3055
- loopThreshold,
3056
- signature: signature,
3057
- action: 'request_terminated',
3058
- totalSteps: steps,
3059
- maxSteps: settings.maxSteps,
3060
- // FULL CONTEXT for debugging
3061
- myPrompt: cleanPayload.messages, // Full conversation sent to LLM
3062
- systemPrompt: cleanPayload.system, // Full system prompt
3063
- llmResponse: databricksResponse?.data || databricksResponse?.json, // Full LLM response that triggered loop
3064
- repeatedToolCalls: toolCalls, // The actual repeated tool calls
3065
- toolCallHistory: Array.from(toolCallHistory.entries()), // Full history of all tool calls in this request
3066
- },
3067
- "Tool call loop limit exceeded - forcing termination (FULL CONTEXT CAPTURED)",
3068
- );
3069
-
3070
- return {
3071
- response: {
3072
- status: 500,
3073
- body: {
3074
- error: {
3075
- type: "tool_call_loop_detected",
3076
- message: `Tool call loop detected: The model called the same tool ("${toolName}") with identical parameters ${count} times (threshold: ${loopThreshold}). This indicates an infinite loop and execution has been terminated. Please try rephrasing your request or provide different parameters.`,
3077
- },
3078
- },
3079
- terminationReason: "tool_call_loop",
3080
- },
3081
- steps,
3082
- durationMs: Date.now() - start,
3083
- terminationReason: "tool_call_loop",
3084
- };
3085
- }
3086
- }
2337
+ };
3087
2338
 
3088
- continue;
2339
+ return {
2340
+ response: {
2341
+ status: 200,
2342
+ body: anthropicResponse,
2343
+ terminationReason: "tool_use",
2344
+ },
2345
+ steps,
2346
+ durationMs: Date.now() - start,
2347
+ terminationReason: "tool_use",
2348
+ };
3089
2349
  }
3090
2350
 
3091
2351
  let anthropicPayload;
@@ -3367,280 +2627,6 @@ IMPORTANT TOOL USAGE RULES:
3367
2627
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3368
2628
  }
3369
2629
 
3370
- // Ensure content is an array before calling .find()
3371
- const content = Array.isArray(anthropicPayload.content) ? anthropicPayload.content : [];
3372
- const fallbackCandidate = content.find(
3373
- (item) => item.type === "text" && needsWebFallback(item.text),
3374
- );
3375
-
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) {
3380
- if (providerType === "azure-anthropic") {
3381
- anthropicPayload.content.push({
3382
- type: "text",
3383
- text: "Automatic web fetch policy fallback is not supported with the Azure-hosted Anthropic provider.",
3384
- });
3385
- fallbackPerformed = true;
3386
- continue;
3387
- }
3388
- const lastUserMessage = cleanPayload.messages
3389
- .slice()
3390
- .reverse()
3391
- .find((msg) => msg.role === "user" && typeof msg.content === "string");
3392
-
3393
- let queryUrl = null;
3394
- if (lastUserMessage) {
3395
- const urlMatch = lastUserMessage.content.match(/(https?:\/\/[^\s"']+)/i);
3396
- if (urlMatch) {
3397
- queryUrl = urlMatch[1];
3398
- }
3399
- }
3400
-
3401
- if (!queryUrl) {
3402
- const text = lastUserMessage?.content ?? "";
3403
- queryUrl = `https://www.google.com/search?q=${encodeURIComponent(text)}`;
3404
- }
3405
-
3406
- if (
3407
- lastUserMessage &&
3408
- /https?:\/\/[^\s"']+/.test(lastUserMessage.content) === false &&
3409
- /price|stock|data|quote/i.test(lastUserMessage.content)
3410
- ) {
3411
- queryUrl = "https://query1.finance.yahoo.com/v8/finance/chart/NVDA";
3412
- }
3413
-
3414
- logger.info(
3415
- {
3416
- sessionId: session?.id ?? null,
3417
- queryUrl,
3418
- },
3419
- "Policy web fallback triggered",
3420
- );
3421
-
3422
- const toolCallId = `policy_web_fetch_${Date.now()}`;
3423
- const toolCall = {
3424
- id: toolCallId,
3425
- function: {
3426
- name: "web_fetch",
3427
- arguments: JSON.stringify({ url: queryUrl }),
3428
- },
3429
- };
3430
-
3431
- const decision = policy.evaluateToolCall({
3432
- call: toolCall,
3433
- toolCallsExecuted,
3434
- });
3435
-
3436
- if (!decision.allowed) {
3437
- anthropicPayload.content.push({
3438
- type: "text",
3439
- text: `Automatic web fetch was blocked: ${decision.reason ?? "policy denied."}`,
3440
- });
3441
- } else {
3442
- const candidateUrls = extractWebSearchUrls(
3443
- cleanPayload.messages,
3444
- { max: 5 },
3445
- toolCallNames,
3446
- );
3447
- const orderedCandidates = [];
3448
- const seenCandidates = new Set();
3449
-
3450
- const pushCandidate = (url) => {
3451
- if (typeof url !== "string") return;
3452
- const trimmed = url.trim();
3453
- if (!/^https?:\/\//i.test(trimmed)) return;
3454
- if (seenCandidates.has(trimmed)) return;
3455
- seenCandidates.add(trimmed);
3456
- orderedCandidates.push(trimmed);
3457
- };
3458
-
3459
- pushCandidate(queryUrl);
3460
- for (const candidate of candidateUrls) {
3461
- pushCandidate(candidate);
3462
- }
3463
-
3464
- if (orderedCandidates.length === 0 && typeof queryUrl === "string") {
3465
- pushCandidate(queryUrl);
3466
- }
3467
-
3468
- if (orderedCandidates.length === 0) {
3469
- anthropicPayload.content.push({
3470
- type: "text",
3471
- text: "Automatic web fetch was skipped: no candidate URLs were available.",
3472
- });
3473
- continue;
3474
- }
3475
-
3476
- let attemptSucceeded = false;
3477
-
3478
- for (let attemptIndex = 0; attemptIndex < orderedCandidates.length; attemptIndex += 1) {
3479
- const targetUrl = orderedCandidates[attemptIndex];
3480
- const attemptId = `${toolCallId}_${attemptIndex}`;
3481
- const attemptCall = {
3482
- id: attemptId,
3483
- function: {
3484
- name: "web_fetch",
3485
- arguments: JSON.stringify({ url: targetUrl }),
3486
- },
3487
- };
3488
- toolCallNames.set(attemptId, "web_fetch");
3489
-
3490
- const assistantToolMessage = createFallbackAssistantMessage(providerType, {
3491
- text: orderedCandidates.length > 1
3492
- ? `Attempting to fetch data via web_fetch fallback (${attemptIndex + 1}/${orderedCandidates.length}).`
3493
- : "Attempting to fetch data via web_fetch fallback.",
3494
- toolCall: attemptCall,
3495
- });
3496
-
3497
- cleanPayload.messages.push(assistantToolMessage);
3498
-
3499
- let sessionFallbackContent;
3500
- if (providerType === "azure-anthropic") {
3501
- // Already in Anthropic format
3502
- sessionFallbackContent = assistantToolMessage.content;
3503
- } else {
3504
- // Convert OpenRouter format to Anthropic format
3505
- const contentBlocks = [];
3506
- if (assistantToolMessage.content && typeof assistantToolMessage.content === 'string' && assistantToolMessage.content.trim()) {
3507
- contentBlocks.push({
3508
- type: "text",
3509
- text: assistantToolMessage.content
3510
- });
3511
- }
3512
-
3513
- if (Array.isArray(assistantToolMessage.tool_calls)) {
3514
- for (const tc of assistantToolMessage.tool_calls) {
3515
- const func = tc.function || {};
3516
- let input = {};
3517
- if (func.arguments) {
3518
- try {
3519
- input = typeof func.arguments === "string" ? JSON.parse(func.arguments) : func.arguments;
3520
- } catch (err) {
3521
- logger.warn({ error: err.message }, "Failed to parse fallback tool arguments");
3522
- input = {};
3523
- }
3524
- }
3525
-
3526
- contentBlocks.push({
3527
- type: "tool_use",
3528
- id: tc.id || `toolu_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
3529
- name: func.name || "unknown",
3530
- input
3531
- });
3532
- }
3533
- }
3534
-
3535
- sessionFallbackContent = contentBlocks;
3536
- }
3537
-
3538
- appendTurnToSession(session, {
3539
- role: "assistant",
3540
- type: "tool_request",
3541
- status: 200,
3542
- content: sessionFallbackContent,
3543
- metadata: {
3544
- termination: "tool_use",
3545
- toolCalls: [{ id: attemptCall.id, name: attemptCall.function.name }],
3546
- fallback: true,
3547
- query: targetUrl,
3548
- attempt: attemptIndex + 1,
3549
- },
3550
- });
3551
-
3552
- const execution = await executeToolCall(attemptCall, {
3553
- session,
3554
- cwd,
3555
- requestMessages: cleanPayload.messages,
3556
- provider: providerType, // Pass provider for GPT-specific formatting
3557
- });
3558
-
3559
- const toolResultMessage = createFallbackToolResultMessage(providerType, {
3560
- toolCall: attemptCall,
3561
- execution,
3562
- });
3563
-
3564
- cleanPayload.messages.push(toolResultMessage);
3565
-
3566
- let sessionFallbackToolResult;
3567
- if (providerType === "azure-anthropic") {
3568
- // Already in Anthropic format
3569
- sessionFallbackToolResult = toolResultMessage.content;
3570
- } else {
3571
- // Convert OpenRouter tool message to Anthropic format
3572
- sessionFallbackToolResult = [
3573
- {
3574
- type: "tool_result",
3575
- tool_use_id: toolResultMessage.tool_call_id,
3576
- content: toolResultMessage.content,
3577
- is_error: execution.ok === false,
3578
- },
3579
- ];
3580
- }
3581
-
3582
- appendTurnToSession(session, {
3583
- role: "tool",
3584
- type: "tool_result",
3585
- status: execution.status,
3586
- content: sessionFallbackToolResult,
3587
- metadata: {
3588
- tool: attemptCall.function.name,
3589
- ok: execution.ok,
3590
- registered: execution.metadata?.registered ?? true,
3591
- fallback: true,
3592
- query: targetUrl,
3593
- attempt: attemptIndex + 1,
3594
- },
3595
- });
3596
-
3597
- toolCallsExecuted += 1;
3598
-
3599
- if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
3600
- logger.error(
3601
- {
3602
- sessionId: session?.id ?? null,
3603
- toolCallsExecuted,
3604
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
3605
- steps,
3606
- },
3607
- "Maximum tool calls per request exceeded during fallback - terminating",
3608
- );
3609
-
3610
- return {
3611
- response: {
3612
- status: 500,
3613
- body: {
3614
- error: {
3615
- type: "max_tool_calls_exceeded",
3616
- message: `Maximum tool calls per request exceeded. The model attempted to execute ${toolCallsExecuted} tool calls, but the limit is ${settings.maxToolCallsPerRequest}. This may indicate a complex task that requires breaking down into smaller steps.`,
3617
- },
3618
- },
3619
- terminationReason: "max_tool_calls_exceeded",
3620
- },
3621
- steps,
3622
- durationMs: Date.now() - start,
3623
- terminationReason: "max_tool_calls_exceeded",
3624
- };
3625
- }
3626
-
3627
- if (execution.ok) {
3628
- fallbackPerformed = true;
3629
- attemptSucceeded = true;
3630
- break;
3631
- }
3632
- }
3633
-
3634
- if (!attemptSucceeded) {
3635
- anthropicPayload.content.push({
3636
- type: "text",
3637
- text: "Automatic web fetch could not retrieve data from any candidate URLs.",
3638
- });
3639
- }
3640
- continue;
3641
- }
3642
- }
3643
-
3644
2630
  // Attach routing metadata for OpenClaw model name rewriting
3645
2631
  if (databricksResponse.routingDecision) {
3646
2632
  anthropicPayload._routingMeta = {
@@ -3659,7 +2645,7 @@ IMPORTANT TOOL USAGE RULES:
3659
2645
  metadata: { termination: "completion" },
3660
2646
  });
3661
2647
 
3662
- if (cacheKey && steps === 1 && toolCallsExecuted === 0) {
2648
+ if (cacheKey && steps === 1) {
3663
2649
  const storedKey = promptCache.storeResponse(cacheKey, databricksResponse);
3664
2650
  if (storedKey) {
3665
2651
  const promptTokens = databricksResponse.json?.usage?.prompt_tokens ?? 0;
@@ -3696,9 +2682,6 @@ IMPORTANT TOOL USAGE RULES:
3696
2682
  {
3697
2683
  sessionId: session?.id ?? null,
3698
2684
  steps,
3699
- toolCallsExecuted,
3700
- uniqueToolSignatures: toolCallHistory.size,
3701
- toolCallLoopWarnings: loopWarningInjected ? 1 : 0,
3702
2685
  durationMs: finalDurationMs,
3703
2686
  avgDurationPerStep: steps > 0 ? Math.round(finalDurationMs / steps) : 0,
3704
2687
  },
@@ -3736,12 +2719,9 @@ IMPORTANT TOOL USAGE RULES:
3736
2719
  {
3737
2720
  sessionId: session?.id ?? null,
3738
2721
  steps,
3739
- toolCallsExecuted,
3740
- uniqueToolSignatures: toolCallHistory.size,
3741
2722
  durationMs: finalDurationMs,
3742
2723
  maxSteps: settings.maxSteps,
3743
2724
  maxDurationMs: settings.maxDurationMs,
3744
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
3745
2725
  },
3746
2726
  "Agent loop exceeded limits",
3747
2727
  );
@@ -3755,11 +2735,9 @@ IMPORTANT TOOL USAGE RULES:
3755
2735
  limits: {
3756
2736
  maxSteps: settings.maxSteps,
3757
2737
  maxDurationMs: settings.maxDurationMs,
3758
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
3759
2738
  },
3760
2739
  metrics: {
3761
2740
  steps,
3762
- toolCallsExecuted,
3763
2741
  durationMs: finalDurationMs,
3764
2742
  },
3765
2743
  },
@@ -3866,11 +2844,8 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3866
2844
 
3867
2845
  // === TOOL LOOP GUARD (EARLY CHECK) ===
3868
2846
  // Check BEFORE sanitization since sanitizePayload removes conversation history
3869
- const executionMode = config.toolExecutionMode || "server";
3870
- const isClientMode = executionMode === "client" || executionMode === "passthrough";
3871
-
3872
- if (isClientMode && session) {
3873
- // === CROSS-REQUEST DEDUP (CLIENT/PASSTHROUGH MODE) ===
2847
+ if (session) {
2848
+ // === CROSS-REQUEST DEDUP ===
3874
2849
  // The inner-loop guard resets each HTTP request so repeated calls across
3875
2850
  // requests escape detection. Track signatures in session metadata instead.
3876
2851
  ensureDedupStructure(session);
@@ -3987,7 +2962,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3987
2962
  if (Array.isArray(payload?.messages)) {
3988
2963
  payload.messages.push({
3989
2964
  role: "user",
3990
- content: `⚠️ CRITICAL SYSTEM WARNING: You have called the "${dedupToolName}" tool ${maxCount} times with identical or similar parameters across multiple requests. This IS an infinite loop. STOP calling this tool immediately. You MUST now provide a direct text response based on the results you have received. If the tool returned "no results" or empty output, that IS the final answer - do not retry. Summarize your findings and respond.`,
2965
+ content: `⚠️ CRITICAL SYSTEM WARNING: You have called the "${dedupToolName}" tool ${maxCount} times with identical or similar parameters across multiple requests. This IS an infinite loop. STOP calling this tool immediately. You MUST now provide a direct text response based on the results you have received. If the tool returned "no results" or empty output, that IS the final answer - do not retry. Your response must contain your actual findings from the tool results gathered so far — NOT an acknowledgment or restatement of this warning. Do not mention this warning in your response.`,
3991
2966
  });
3992
2967
  }
3993
2968
  }
@@ -4011,16 +2986,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4011
2986
  const cleanPayload = sanitizePayload(payload);
4012
2987
  pTimer.mark("sanitizePayload");
4013
2988
 
4014
- try {
4015
- const { loaded } = lazyLoader.ensureToolsForPrompt(cleanPayload.messages);
4016
- if (loaded.length > 0) {
4017
- logger.debug({ loaded }, "Proactively loaded tool categories for prompt");
4018
- }
4019
- } catch (err) {
4020
- logger.debug({ error: err.message }, "Lazy tool loading check failed");
4021
- }
4022
- pTimer.mark("lazyToolLoad");
4023
-
4024
2989
  appendTurnToSession(session, {
4025
2990
  role: "user",
4026
2991
  content: {