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