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.
- package/README.md +92 -23
- package/bin/cli.js +13 -7
- package/bin/lynkr-init.js +34 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +144 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +292 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/context/tool-result-compressor.js +883 -40
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +261 -0
- package/src/routing/index.js +70 -7
- package/src/routing/intent-score.js +132 -12
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +20 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- 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
|
-
//
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
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
|
-
//
|
|
1411
|
-
// Lynkr synthesises SSE back to the client
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3870
|
-
|
|
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: {
|