lynkr 9.9.0 → 9.10.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 (73) hide show
  1. package/README.md +92 -23
  2. package/bin/cli.js +13 -7
  3. package/bin/lynkr-init.js +34 -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/scripts/build-eval-set.js +256 -0
  8. package/scripts/mine-difficulty-anchors.js +288 -0
  9. package/scripts/validate-difficulty-classifier.js +144 -0
  10. package/scripts/validate-intent-anchors.js +186 -0
  11. package/src/api/providers-handler.js +0 -1
  12. package/src/api/router.js +292 -160
  13. package/src/clients/databricks.js +95 -6
  14. package/src/config/index.js +3 -43
  15. package/src/context/tool-result-compressor.js +883 -40
  16. package/src/orchestrator/index.js +117 -1235
  17. package/src/orchestrator/passthrough-stream.js +382 -0
  18. package/src/orchestrator/sse-transformer.js +408 -0
  19. package/src/routing/affinity-store.js +17 -3
  20. package/src/routing/classifier-setup.js +207 -0
  21. package/src/routing/complexity-analyzer.js +40 -4
  22. package/src/routing/difficulty-classifier.js +261 -0
  23. package/src/routing/index.js +70 -7
  24. package/src/routing/intent-score.js +132 -12
  25. package/src/routing/session-affinity.js +8 -1
  26. package/src/routing/side-channel-detector.js +103 -0
  27. package/src/server.js +20 -46
  28. package/src/agents/context-manager.js +0 -236
  29. package/src/agents/decomposition/dispatcher.js +0 -185
  30. package/src/agents/decomposition/gate.js +0 -136
  31. package/src/agents/decomposition/index.js +0 -183
  32. package/src/agents/decomposition/model-call.js +0 -75
  33. package/src/agents/decomposition/planner.js +0 -223
  34. package/src/agents/decomposition/synthesizer.js +0 -89
  35. package/src/agents/decomposition/telemetry.js +0 -55
  36. package/src/agents/definitions/loader.js +0 -653
  37. package/src/agents/executor.js +0 -457
  38. package/src/agents/index.js +0 -165
  39. package/src/agents/parallel-coordinator.js +0 -68
  40. package/src/agents/reflector.js +0 -331
  41. package/src/agents/skillbook.js +0 -331
  42. package/src/agents/store.js +0 -259
  43. package/src/edits/index.js +0 -171
  44. package/src/indexer/babel-parser.js +0 -213
  45. package/src/indexer/index.js +0 -1629
  46. package/src/indexer/navigation/index.js +0 -32
  47. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  48. package/src/indexer/parser.js +0 -443
  49. package/src/tasks/store.js +0 -349
  50. package/src/tests/coverage.js +0 -173
  51. package/src/tests/index.js +0 -171
  52. package/src/tests/store.js +0 -213
  53. package/src/tools/agent-task.js +0 -145
  54. package/src/tools/code-mode.js +0 -304
  55. package/src/tools/decompose.js +0 -91
  56. package/src/tools/edits.js +0 -94
  57. package/src/tools/execution.js +0 -171
  58. package/src/tools/git.js +0 -1346
  59. package/src/tools/index.js +0 -306
  60. package/src/tools/indexer.js +0 -360
  61. package/src/tools/lazy-loader.js +0 -366
  62. package/src/tools/mcp-remote.js +0 -88
  63. package/src/tools/mcp.js +0 -116
  64. package/src/tools/process.js +0 -167
  65. package/src/tools/smart-selection.js +0 -180
  66. package/src/tools/stubs.js +0 -55
  67. package/src/tools/tasks.js +0 -260
  68. package/src/tools/tests.js +0 -132
  69. package/src/tools/tinyfish.js +0 -358
  70. package/src/tools/truncate.js +0 -106
  71. package/src/tools/web-client.js +0 -71
  72. package/src/tools/web.js +0 -415
  73. 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,7 +19,6 @@ 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");
28
24
 
@@ -359,49 +355,11 @@ function removeMatchingAssistantToolUse(cleanMessages, toolUseId) {
359
355
  }
360
356
  }
361
357
 
362
- const WEB_SEARCH_NORMALIZED = new Set(["websearch", "web_search", "web-search"]);
363
358
 
364
359
  function normaliseToolIdentifier(name = "") {
365
360
  return String(name).toLowerCase().replace(/[^a-z0-9]/g, "");
366
361
  }
367
362
 
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
363
 
406
364
  /**
407
365
  * Count tool_use and tool_result blocks in message history.
@@ -710,205 +668,6 @@ function sanitiseAzureTools(tools) {
710
668
  return cleaned.size > 0 ? Array.from(cleaned.values()) : undefined;
711
669
  }
712
670
 
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
671
  function normaliseToolChoice(choice) {
913
672
  if (!choice) return undefined;
914
673
  if (typeof choice === "string") return choice; // "auto", "none"
@@ -1381,34 +1140,13 @@ function sanitizePayload(payload) {
1381
1140
  delete clean.tool_choice;
1382
1141
  }
1383
1142
 
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
- }
1143
+ // The client owns tool execution its tools always pass through intact.
1144
+ // Stripping any would make the model emit calls for tools we removed; they
1145
+ // then get dropped as "hallucinated" and the session makes no progress.
1409
1146
 
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.
1147
+ // Default false: the buffered path parses tool calls from complete JSON
1148
+ // and Lynkr synthesises SSE back to the client. runAgentLoop flips this to
1149
+ // true per-step when the Phase-2b stream transform is active.
1412
1150
  clean.stream = false;
1413
1151
 
1414
1152
  if (
@@ -1504,8 +1242,10 @@ function sanitizePayload(payload) {
1504
1242
  return clean;
1505
1243
  }
1506
1244
 
1245
+ // maxSteps default is 2, not 1: the loop no longer executes tools (the client
1246
+ // owns them), but hallucination recovery still needs one re-prompt iteration.
1507
1247
  const DEFAULT_LOOP_OPTIONS = {
1508
- maxSteps: config.policy.maxStepsPerTurn ?? 6,
1248
+ maxSteps: config.policy.maxStepsPerTurn ?? 2,
1509
1249
  maxDurationMs: 120000,
1510
1250
  maxToolCallsPerRequest: config.policy.maxToolCallsPerRequest ?? 20, // Prevent runaway tool calling
1511
1251
  };
@@ -1605,11 +1345,6 @@ async function runAgentLoop({
1605
1345
  const auditLogger = createAuditLogger(config.audit);
1606
1346
  const start = Date.now();
1607
1347
  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
1348
 
1614
1349
  while (steps < settings.maxSteps) {
1615
1350
  if (Date.now() - start > settings.maxDurationMs) {
@@ -1622,7 +1357,6 @@ async function runAgentLoop({
1622
1357
  {
1623
1358
  sessionId: session?.id ?? null,
1624
1359
  steps,
1625
- toolCallsExecuted,
1626
1360
  durationMs: Date.now() - start,
1627
1361
  },
1628
1362
  "Agent loop interrupted - system shutting down",
@@ -2087,6 +1821,17 @@ IMPORTANT TOOL USAGE RULES:
2087
1821
  logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)');
2088
1822
  }
2089
1823
 
1824
+ // Phase 2b — cross-format streaming (opt-in via LYNKR_STREAM_TRANSFORM).
1825
+ // When the client wants a stream and the upstream speaks OpenAI SSE,
1826
+ // request a streamed upstream response and reshape it in flight instead of
1827
+ // buffering. Only on the first step: a hallucination-recovery re-prompt
1828
+ // needs a parsed, buffered response.
1829
+ const sseTransform = require("./sse-transformer");
1830
+ const _streamProvider = cleanPayload._forceProvider || providerType;
1831
+ const _wantsTransformStream =
1832
+ steps === 1 && sseTransform.shouldTransform(options?.clientWantsStream, _streamProvider);
1833
+ cleanPayload.stream = _wantsTransformStream;
1834
+
2090
1835
  if (agentTimer) agentTimer.mark("preInvokeModel");
2091
1836
  let databricksResponse;
2092
1837
  // Honor a body-level forceProvider marker (set by the OAuth tier-routing
@@ -2186,15 +1931,76 @@ IMPORTANT TOOL USAGE RULES:
2186
1931
  }
2187
1932
  }
2188
1933
 
2189
- // Handle streaming responses (pass through without buffering)
2190
- if (databricksResponse.stream) {
1934
+ // Handle streaming responses (pass through without buffering). An
1935
+ // ok:false "stream" is a provider error whose body was already consumed
1936
+ // for logging — let it fall through to the error branches below instead
1937
+ // of synthesizing an empty completion from a spent stream.
1938
+ if (databricksResponse.stream && databricksResponse.ok !== false) {
2191
1939
  logger.debug(
2192
1940
  {
2193
1941
  sessionId: session?.id ?? null,
2194
1942
  status: databricksResponse.status,
1943
+ transform: _wantsTransformStream,
2195
1944
  },
2196
1945
  "Streaming response received, passing through"
2197
1946
  );
1947
+
1948
+ // Phase 2b: reshape OpenAI SSE into Anthropic SSE in flight. All
1949
+ // telemetry moves to the onClose finalizer — tool names/arg sizes are
1950
+ // accumulated DURING the stream, usage arrives with the final chunks.
1951
+ if (_wantsTransformStream) {
1952
+ const streamStartedAt = Date.now();
1953
+ const routingDecision = databricksResponse.routingDecision || {};
1954
+ const transformed = sseTransform.openaiToAnthropicSSE(databricksResponse.stream, {
1955
+ model: requestedModel,
1956
+ badgeText: config.routing?.visibleInteraction ? options?.streamBadgeText || null : null,
1957
+ onClose: (stats) => {
1958
+ try {
1959
+ const telemetry = require("../routing/telemetry");
1960
+ telemetry.record({
1961
+ // request_id is NOT NULL in the telemetry schema — a null id
1962
+ // silently drops the whole row.
1963
+ request_id: options?.correlationId || crypto.randomUUID(),
1964
+ session_id: session?.id ?? null,
1965
+ timestamp: streamStartedAt,
1966
+ tier: routingDecision.tier ?? null,
1967
+ provider: (routingDecision.provider || _streamProvider) + "",
1968
+ model: routingDecision.model ?? cleanPayload.model ?? null,
1969
+ routing_method: "stream-transform",
1970
+ status_code: stats.stopReason === "stream_error" ? 599 : 200,
1971
+ latency_ms: Date.now() - streamStartedAt,
1972
+ input_tokens: stats.usage.input_tokens,
1973
+ output_tokens: stats.usage.output_tokens,
1974
+ message_count: cleanPayload.messages?.length ?? null,
1975
+ tool_count: Array.isArray(cleanPayload.tools) ? cleanPayload.tools.length : 0,
1976
+ tool_calls_made: stats.toolCalls.length,
1977
+ error_type: stats.stopReason === "stream_error" ? "stream_error" : null,
1978
+ was_fallback: false,
1979
+ });
1980
+ logger.info({
1981
+ provider: routingDecision.provider || _streamProvider,
1982
+ stopReason: stats.stopReason,
1983
+ toolCalls: stats.toolCalls.map((t) => t.name),
1984
+ outputTokens: stats.usage.output_tokens,
1985
+ }, "[SSETransform] Stream closed");
1986
+ } catch (err) {
1987
+ logger.debug({ err: err.message }, "[SSETransform] Telemetry finalizer failed (non-fatal)");
1988
+ }
1989
+ },
1990
+ });
1991
+ return {
1992
+ response: {
1993
+ status: databricksResponse.status,
1994
+ headers: { "Content-Type": "text/event-stream" },
1995
+ stream: transformed,
1996
+ terminationReason: "streaming",
1997
+ },
1998
+ steps,
1999
+ durationMs: Date.now() - start,
2000
+ terminationReason: "streaming",
2001
+ };
2002
+ }
2003
+
2198
2004
  return {
2199
2005
  response: buildStreamingResponse(databricksResponse),
2200
2006
  steps,
@@ -2346,11 +2152,7 @@ IMPORTANT TOOL USAGE RULES:
2346
2152
  ? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
2347
2153
  : (typeof message.content === "string" && message.content.trim().length > 0);
2348
2154
 
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) {
2155
+ if (!hasTextContent && steps < settings.maxSteps) {
2354
2156
  logger.info({
2355
2157
  sessionId: session?.id ?? null,
2356
2158
  step: steps,
@@ -2429,663 +2231,38 @@ IMPORTANT TOOL USAGE RULES:
2429
2231
  },
2430
2232
  });
2431
2233
 
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,
2234
+ // The client owns every tool: forward the tool_use turn untouched and
2235
+ // end this request the client executes the tools and sends the
2236
+ // results back as a fresh request.
2237
+ //
2238
+ // Do NOT record outbound tool calls here the inbound recording on
2239
+ // the next request (when the client sends results back) is enough to
2240
+ // detect real loops. Recording both outbound + inbound for the same
2241
+ // call double-counts and triggers the dedup warning on the very first
2242
+ // normal tool round-trip.
2243
+ const anthropicResponse = {
2244
+ id: databricksResponse.json?.id || `msg_${Date.now()}`,
2245
+ type: "message",
2246
+ role: "assistant",
2247
+ content: sessionContent,
2248
+ model: databricksResponse.json?.model || cleanPayload.model,
2249
+ stop_reason: "tool_use",
2250
+ usage: databricksResponse.json?.usage || {
2251
+ input_tokens: 0,
2252
+ output_tokens: 0,
2583
2253
  },
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
- }
2254
+ };
3087
2255
 
3088
- continue;
2256
+ return {
2257
+ response: {
2258
+ status: 200,
2259
+ body: anthropicResponse,
2260
+ terminationReason: "tool_use",
2261
+ },
2262
+ steps,
2263
+ durationMs: Date.now() - start,
2264
+ terminationReason: "tool_use",
2265
+ };
3089
2266
  }
3090
2267
 
3091
2268
  let anthropicPayload;
@@ -3367,280 +2544,6 @@ IMPORTANT TOOL USAGE RULES:
3367
2544
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
3368
2545
  }
3369
2546
 
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
2547
  // Attach routing metadata for OpenClaw model name rewriting
3645
2548
  if (databricksResponse.routingDecision) {
3646
2549
  anthropicPayload._routingMeta = {
@@ -3659,7 +2562,7 @@ IMPORTANT TOOL USAGE RULES:
3659
2562
  metadata: { termination: "completion" },
3660
2563
  });
3661
2564
 
3662
- if (cacheKey && steps === 1 && toolCallsExecuted === 0) {
2565
+ if (cacheKey && steps === 1) {
3663
2566
  const storedKey = promptCache.storeResponse(cacheKey, databricksResponse);
3664
2567
  if (storedKey) {
3665
2568
  const promptTokens = databricksResponse.json?.usage?.prompt_tokens ?? 0;
@@ -3696,9 +2599,6 @@ IMPORTANT TOOL USAGE RULES:
3696
2599
  {
3697
2600
  sessionId: session?.id ?? null,
3698
2601
  steps,
3699
- toolCallsExecuted,
3700
- uniqueToolSignatures: toolCallHistory.size,
3701
- toolCallLoopWarnings: loopWarningInjected ? 1 : 0,
3702
2602
  durationMs: finalDurationMs,
3703
2603
  avgDurationPerStep: steps > 0 ? Math.round(finalDurationMs / steps) : 0,
3704
2604
  },
@@ -3736,12 +2636,9 @@ IMPORTANT TOOL USAGE RULES:
3736
2636
  {
3737
2637
  sessionId: session?.id ?? null,
3738
2638
  steps,
3739
- toolCallsExecuted,
3740
- uniqueToolSignatures: toolCallHistory.size,
3741
2639
  durationMs: finalDurationMs,
3742
2640
  maxSteps: settings.maxSteps,
3743
2641
  maxDurationMs: settings.maxDurationMs,
3744
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
3745
2642
  },
3746
2643
  "Agent loop exceeded limits",
3747
2644
  );
@@ -3755,11 +2652,9 @@ IMPORTANT TOOL USAGE RULES:
3755
2652
  limits: {
3756
2653
  maxSteps: settings.maxSteps,
3757
2654
  maxDurationMs: settings.maxDurationMs,
3758
- maxToolCallsPerRequest: settings.maxToolCallsPerRequest,
3759
2655
  },
3760
2656
  metrics: {
3761
2657
  steps,
3762
- toolCallsExecuted,
3763
2658
  durationMs: finalDurationMs,
3764
2659
  },
3765
2660
  },
@@ -3866,11 +2761,8 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3866
2761
 
3867
2762
  // === TOOL LOOP GUARD (EARLY CHECK) ===
3868
2763
  // 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) ===
2764
+ if (session) {
2765
+ // === CROSS-REQUEST DEDUP ===
3874
2766
  // The inner-loop guard resets each HTTP request so repeated calls across
3875
2767
  // requests escape detection. Track signatures in session metadata instead.
3876
2768
  ensureDedupStructure(session);
@@ -4011,16 +2903,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
4011
2903
  const cleanPayload = sanitizePayload(payload);
4012
2904
  pTimer.mark("sanitizePayload");
4013
2905
 
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
2906
  appendTurnToSession(session, {
4025
2907
  role: "user",
4026
2908
  content: {