lynkr 9.7.3 → 9.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
|
@@ -12,6 +12,7 @@ const systemPrompt = require("../prompts/system");
|
|
|
12
12
|
const historyCompression = require("../context/compression");
|
|
13
13
|
const tokenBudget = require("../context/budget");
|
|
14
14
|
const { applyToonCompression } = require("../context/toon");
|
|
15
|
+
const { applyGcfCompression } = require("../context/gcf");
|
|
15
16
|
const { classifyRequestType, selectToolsSmartly } = require("../tools/smart-selection");
|
|
16
17
|
const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom");
|
|
17
18
|
const { createAuditLogger } = require("../logger/audit-logger");
|
|
@@ -20,7 +21,6 @@ const { getShuttingDown } = require("../api/health");
|
|
|
20
21
|
const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
|
|
21
22
|
const { detectBypass, buildBypassResponse } = require("./bypass");
|
|
22
23
|
const crypto = require("crypto");
|
|
23
|
-
const { asyncClone, asyncTransform, getPoolStats } = require("../workers/helpers");
|
|
24
24
|
const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
|
|
25
25
|
const lazyLoader = require("../tools/lazy-loader");
|
|
26
26
|
const { areSimilarToolCalls } = require("../clients/gpt-utils");
|
|
@@ -43,6 +43,8 @@ function getDestinationUrl(providerType) {
|
|
|
43
43
|
return config.azureOpenAI?.endpoint ?? 'unknown';
|
|
44
44
|
case 'openrouter':
|
|
45
45
|
return config.openrouter?.endpoint ?? 'unknown';
|
|
46
|
+
case 'edenai':
|
|
47
|
+
return config.edenai?.endpoint ?? 'unknown';
|
|
46
48
|
case 'openai':
|
|
47
49
|
return 'https://api.openai.com/v1/chat/completions';
|
|
48
50
|
case 'llamacpp':
|
|
@@ -235,10 +237,18 @@ function normaliseTools(tools) {
|
|
|
235
237
|
function ensureAnthropicToolFormat(tools) {
|
|
236
238
|
if (!Array.isArray(tools) || tools.length === 0) return undefined;
|
|
237
239
|
return tools.map((tool) => {
|
|
240
|
+
// Shape-detect: unwrap OpenAI-format tools rather than fabricating
|
|
241
|
+
// "unnamed_tool" from their absent top-level fields.
|
|
242
|
+
if (tool?.type === "function" && tool.function) {
|
|
243
|
+
tool = {
|
|
244
|
+
name: tool.function.name,
|
|
245
|
+
description: tool.function.description,
|
|
246
|
+
input_schema: tool.function.parameters,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
238
249
|
// Ensure input_schema has required 'type' field
|
|
239
250
|
let input_schema = tool.input_schema || { type: "object", properties: {} };
|
|
240
251
|
|
|
241
|
-
// If input_schema exists but missing 'type', add it
|
|
242
252
|
if (input_schema && !input_schema.type) {
|
|
243
253
|
input_schema = { type: "object", ...input_schema };
|
|
244
254
|
}
|
|
@@ -468,7 +478,6 @@ function injectToolLoopStopInstruction(messages, threshold = 5) {
|
|
|
468
478
|
content: `⚠️ IMPORTANT: You have already executed ${toolResultCount} tool calls in this conversation. This is likely an infinite loop. STOP calling tools immediately and provide a direct text response to the user based on the information you have gathered. If you cannot complete the task, explain why. DO NOT call any more tools.`,
|
|
469
479
|
};
|
|
470
480
|
|
|
471
|
-
// Add to end of messages
|
|
472
481
|
return [...messages, stopInstruction];
|
|
473
482
|
}
|
|
474
483
|
|
|
@@ -550,7 +559,6 @@ function recordCrossRequestToolCall(session, toolCall) {
|
|
|
550
559
|
}
|
|
551
560
|
|
|
552
561
|
if (!mergedInto) {
|
|
553
|
-
// New unique signature
|
|
554
562
|
dedup.signatures[signature] = {
|
|
555
563
|
count: 1,
|
|
556
564
|
toolName,
|
|
@@ -610,7 +618,6 @@ function getMaxDedupCount(session) {
|
|
|
610
618
|
function extractToolUseFromCurrentTurn(messages) {
|
|
611
619
|
if (!Array.isArray(messages)) return [];
|
|
612
620
|
|
|
613
|
-
// Find last user text message
|
|
614
621
|
let lastUserTextIndex = -1;
|
|
615
622
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
616
623
|
const msg = messages[i];
|
|
@@ -731,7 +738,6 @@ function parseExecutionContent(content) {
|
|
|
731
738
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
732
739
|
return block.text;
|
|
733
740
|
}
|
|
734
|
-
// Handle other block types gracefully
|
|
735
741
|
if (block.text) return block.text;
|
|
736
742
|
if (block.content) return typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
737
743
|
return null;
|
|
@@ -922,7 +928,6 @@ function normaliseToolChoice(choice) {
|
|
|
922
928
|
function stripThinkingBlocks(text) {
|
|
923
929
|
if (typeof text !== "string") return text;
|
|
924
930
|
|
|
925
|
-
// Split into lines
|
|
926
931
|
const lines = text.split("\n");
|
|
927
932
|
const cleanedLines = [];
|
|
928
933
|
let inThinkingBlock = false;
|
|
@@ -947,15 +952,12 @@ function stripThinkingBlocks(text) {
|
|
|
947
952
|
continue;
|
|
948
953
|
}
|
|
949
954
|
|
|
950
|
-
// Reset empty line counter
|
|
951
955
|
consecutiveEmptyLines = 0;
|
|
952
956
|
|
|
953
|
-
// Skip lines that are part of thinking block
|
|
954
957
|
if (inThinkingBlock) {
|
|
955
958
|
continue;
|
|
956
959
|
}
|
|
957
960
|
|
|
958
|
-
// Keep this line
|
|
959
961
|
cleanedLines.push(line);
|
|
960
962
|
}
|
|
961
963
|
|
|
@@ -1318,8 +1320,8 @@ function sanitizePayload(payload) {
|
|
|
1318
1320
|
if (Array.isArray(clean.tools) && clean.tools.length > 0) {
|
|
1319
1321
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1320
1322
|
}
|
|
1321
|
-
} else if (providerType === "openrouter") {
|
|
1322
|
-
// OpenRouter
|
|
1323
|
+
} else if (providerType === "openrouter" || providerType === "edenai") {
|
|
1324
|
+
// OpenRouter / Eden AI (OpenAI-compatible) support tools - keep them as-is.
|
|
1323
1325
|
// Tools are already in Anthropic format and will be converted by openrouter-utils
|
|
1324
1326
|
if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
|
|
1325
1327
|
delete clean.tools;
|
|
@@ -1330,7 +1332,6 @@ function sanitizePayload(payload) {
|
|
|
1330
1332
|
if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
|
|
1331
1333
|
delete clean.tools;
|
|
1332
1334
|
} else {
|
|
1333
|
-
// Ensure tools are in Anthropic format
|
|
1334
1335
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1335
1336
|
}
|
|
1336
1337
|
} else if (providerType === "vertex") {
|
|
@@ -1348,6 +1349,15 @@ function sanitizePayload(payload) {
|
|
|
1348
1349
|
} else {
|
|
1349
1350
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1350
1351
|
}
|
|
1352
|
+
} else if (providerType === "azure-openai" || providerType === "openai") {
|
|
1353
|
+
// Azure OpenAI / OpenAI support tools — keep Anthropic format; the
|
|
1354
|
+
// client converts to Chat Completions / Responses format. Without this
|
|
1355
|
+
// branch the unknown-provider catch-all below deletes the tools.
|
|
1356
|
+
if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
|
|
1357
|
+
delete clean.tools;
|
|
1358
|
+
} else {
|
|
1359
|
+
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1360
|
+
}
|
|
1351
1361
|
} else if (Array.isArray(clean.tools)) {
|
|
1352
1362
|
// Unknown provider - remove tools for safety
|
|
1353
1363
|
delete clean.tools;
|
|
@@ -1431,9 +1441,14 @@ function sanitizePayload(payload) {
|
|
|
1431
1441
|
|
|
1432
1442
|
// Optional TOON conversion for large JSON message payloads (prompt context only).
|
|
1433
1443
|
// Run this BEFORE message coalescing to preserve parseable JSON boundaries.
|
|
1434
|
-
|
|
1444
|
+
// GCF takes precedence when enabled; otherwise TOON. Both are opt-in and mutually exclusive.
|
|
1445
|
+
if (config.gcf && config.gcf.enabled) {
|
|
1446
|
+
applyGcfCompression(clean, config.gcf, { logger });
|
|
1447
|
+
} else {
|
|
1448
|
+
applyToonCompression(clean, config.toon, { logger });
|
|
1449
|
+
}
|
|
1435
1450
|
|
|
1436
|
-
//
|
|
1451
|
+
// Handle consecutive messages with the same role (causes llama.cpp 400 error)
|
|
1437
1452
|
// Strategy: Merge consecutive same-role messages, but NEVER merge messages
|
|
1438
1453
|
// that contain tool_use or tool_result blocks — they must stay intact for
|
|
1439
1454
|
// the provider's tool-call protocol.
|
|
@@ -1640,17 +1655,21 @@ async function runAgentLoop({
|
|
|
1640
1655
|
"Agent loop step",
|
|
1641
1656
|
);
|
|
1642
1657
|
|
|
1643
|
-
// Trim
|
|
1644
|
-
//
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1658
|
+
// Trim over-long loop conversations to prevent OOM, keeping the head,
|
|
1659
|
+
// THE CURRENT TASK (latest user message with real typed text), and the
|
|
1660
|
+
// recent tail — a head+tail-only trim discards the ask itself when the
|
|
1661
|
+
// session opened with a greeting, and the model answers the greeting.
|
|
1662
|
+
// Tunable: LYNKR_MAX_LOOP_MESSAGES (default 40; 0 disables trimming
|
|
1663
|
+
// entirely). Disabling trades flat per-frame cost/latency for full
|
|
1664
|
+
// context fidelity — on weak models expect coherence loss on very long
|
|
1665
|
+
// loops (their competence degrades before their context window fills),
|
|
1666
|
+
// and on paid models expect per-frame input cost to grow with session
|
|
1667
|
+
// length. The task-preservation fix makes the default cap safe.
|
|
1668
|
+
const MAX_LOOP_MESSAGES = Number.isFinite(Number(process.env.LYNKR_MAX_LOOP_MESSAGES))
|
|
1669
|
+
? Number(process.env.LYNKR_MAX_LOOP_MESSAGES)
|
|
1670
|
+
: 40;
|
|
1671
|
+
if (MAX_LOOP_MESSAGES > 0 && cleanPayload.messages && cleanPayload.messages.length > MAX_LOOP_MESSAGES) {
|
|
1672
|
+
cleanPayload.messages = trimLoopMessages(cleanPayload.messages, MAX_LOOP_MESSAGES);
|
|
1654
1673
|
}
|
|
1655
1674
|
|
|
1656
1675
|
// Debug: Log payload before sending to Azure
|
|
@@ -1717,7 +1736,6 @@ async function runAgentLoop({
|
|
|
1717
1736
|
memoriesRetrieved: relevantMemories.length,
|
|
1718
1737
|
}, 'Injecting long-term memories into context');
|
|
1719
1738
|
|
|
1720
|
-
// Inject memories into system prompt
|
|
1721
1739
|
const injectedSystem = memoryRetriever.injectMemoriesIntoSystem(
|
|
1722
1740
|
cleanPayload.system,
|
|
1723
1741
|
relevantMemories,
|
|
@@ -1924,9 +1942,9 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1924
1942
|
// Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
|
|
1925
1943
|
// analogous gate.
|
|
1926
1944
|
const headroomProviderMap = {
|
|
1927
|
-
'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
|
|
1928
|
-
'openai': new Set(['azure-openai', 'openai', 'openrouter']),
|
|
1929
|
-
'google': new Set(['vertex', 'openrouter']),
|
|
1945
|
+
'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter', 'edenai']),
|
|
1946
|
+
'openai': new Set(['azure-openai', 'openai', 'openrouter', 'edenai']),
|
|
1947
|
+
'google': new Set(['vertex', 'openrouter', 'edenai']),
|
|
1930
1948
|
};
|
|
1931
1949
|
const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
|
|
1932
1950
|
const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
|
|
@@ -1985,7 +2003,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1985
2003
|
// Generate correlation ID for request/response pairing
|
|
1986
2004
|
const correlationId = `req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
|
|
1987
2005
|
|
|
1988
|
-
// Log LLM request before invocation
|
|
1989
2006
|
if (auditLogger.enabled) {
|
|
1990
2007
|
auditLogger.logLlmRequest({
|
|
1991
2008
|
correlationId,
|
|
@@ -2049,6 +2066,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2049
2066
|
'bedrock',
|
|
2050
2067
|
'vertex',
|
|
2051
2068
|
'openrouter',
|
|
2069
|
+
'edenai',
|
|
2052
2070
|
'ollama',
|
|
2053
2071
|
'openai',
|
|
2054
2072
|
'azure-openai',
|
|
@@ -2107,7 +2125,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2107
2125
|
throw modelError;
|
|
2108
2126
|
}
|
|
2109
2127
|
|
|
2110
|
-
// Extract and log actual token usage
|
|
2111
2128
|
const actualUsage = databricksResponse.ok && config.tokenTracking?.enabled !== false
|
|
2112
2129
|
? tokens.extractUsageFromResponse(databricksResponse.json)
|
|
2113
2130
|
: null;
|
|
@@ -2121,12 +2138,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2121
2138
|
}
|
|
2122
2139
|
}
|
|
2123
2140
|
|
|
2124
|
-
// Log LLM response after invocation
|
|
2125
2141
|
if (auditLogger.enabled) {
|
|
2126
2142
|
const latencyMs = Date.now() - start;
|
|
2127
2143
|
|
|
2128
2144
|
if (databricksResponse.stream) {
|
|
2129
|
-
// Log streaming response (no content, just metadata)
|
|
2130
2145
|
auditLogger.logLlmResponse({
|
|
2131
2146
|
correlationId,
|
|
2132
2147
|
sessionId: session?.id ?? null,
|
|
@@ -2139,7 +2154,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2139
2154
|
streamingNote: 'Content streamed directly to client, not captured in audit log',
|
|
2140
2155
|
});
|
|
2141
2156
|
} else if (databricksResponse.ok && databricksResponse.json) {
|
|
2142
|
-
// Log successful non-streaming response
|
|
2143
2157
|
const message = databricksResponse.json;
|
|
2144
2158
|
const assistantMessage = message.content ?? message.choices?.[0]?.message;
|
|
2145
2159
|
|
|
@@ -2158,7 +2172,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2158
2172
|
status: databricksResponse.status,
|
|
2159
2173
|
});
|
|
2160
2174
|
} else {
|
|
2161
|
-
// Log error response
|
|
2162
2175
|
auditLogger.logLlmResponse({
|
|
2163
2176
|
correlationId,
|
|
2164
2177
|
sessionId: session?.id ?? null,
|
|
@@ -2333,7 +2346,11 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2333
2346
|
? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
|
|
2334
2347
|
: (typeof message.content === "string" && message.content.trim().length > 0);
|
|
2335
2348
|
|
|
2336
|
-
|
|
2349
|
+
// The artifact redirect is an open-design (SERVER-mode) feature; in
|
|
2350
|
+
// client mode, drop the hallucinated calls and return whatever text
|
|
2351
|
+
// exists — never instruct the model to emit artifacts.
|
|
2352
|
+
const _clientOwnsOutput = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
|
|
2353
|
+
if (!hasTextContent && steps < settings.maxSteps - 1 && !_clientOwnsOutput) {
|
|
2337
2354
|
logger.info({
|
|
2338
2355
|
sessionId: session?.id ?? null,
|
|
2339
2356
|
step: steps,
|
|
@@ -2362,7 +2379,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2362
2379
|
const contentBlocks = [];
|
|
2363
2380
|
let toolCallIdx = 0;
|
|
2364
2381
|
|
|
2365
|
-
// Add text content if present
|
|
2366
2382
|
if (message.content && typeof message.content === 'string' && message.content.trim()) {
|
|
2367
2383
|
contentBlocks.push({
|
|
2368
2384
|
type: "text",
|
|
@@ -2370,12 +2386,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2370
2386
|
});
|
|
2371
2387
|
}
|
|
2372
2388
|
|
|
2373
|
-
// Add tool_use blocks from tool_calls
|
|
2374
2389
|
for (const toolCall of toolCalls) {
|
|
2375
2390
|
const func = toolCall.function || {};
|
|
2376
2391
|
let input = {};
|
|
2377
2392
|
|
|
2378
|
-
// Parse arguments string to object
|
|
2379
2393
|
if (func.arguments) {
|
|
2380
2394
|
try {
|
|
2381
2395
|
input = typeof func.arguments === "string"
|
|
@@ -2448,15 +2462,18 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2448
2462
|
|
|
2449
2463
|
cleanPayload.messages.push(assistantToolMessage);
|
|
2450
2464
|
|
|
2451
|
-
// Check if tool execution should happen on client side
|
|
2452
2465
|
const executionMode = config.toolExecutionMode || "server";
|
|
2453
2466
|
|
|
2454
|
-
//
|
|
2455
|
-
//
|
|
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.
|
|
2456
2471
|
const serverSideToolCalls = [];
|
|
2457
2472
|
const clientSideToolCalls = [];
|
|
2458
2473
|
|
|
2459
|
-
const SERVER_SIDE_TOOLS =
|
|
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"]);
|
|
2460
2477
|
|
|
2461
2478
|
for (const call of toolCalls) {
|
|
2462
2479
|
const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
|
|
@@ -2608,7 +2625,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2608
2625
|
}, "Executing multiple Task tools in parallel");
|
|
2609
2626
|
|
|
2610
2627
|
try {
|
|
2611
|
-
// Execute all Task tools in parallel
|
|
2612
2628
|
const taskExecutions = await Promise.all(
|
|
2613
2629
|
taskCalls.map(({ call }) => executeToolCall(call, {
|
|
2614
2630
|
session,
|
|
@@ -2618,7 +2634,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2618
2634
|
}))
|
|
2619
2635
|
);
|
|
2620
2636
|
|
|
2621
|
-
// Process results and add to messages
|
|
2622
2637
|
taskExecutions.forEach((execution, index) => {
|
|
2623
2638
|
const call = taskCalls[index].call;
|
|
2624
2639
|
toolCallsExecuted += 1;
|
|
@@ -2782,7 +2797,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2782
2797
|
|
|
2783
2798
|
cleanPayload.messages.push(toolResultMessage);
|
|
2784
2799
|
|
|
2785
|
-
// Convert to Anthropic format for session storage
|
|
2786
2800
|
let sessionToolResult;
|
|
2787
2801
|
if (providerType === "azure-anthropic") {
|
|
2788
2802
|
sessionToolResult = toolResultMessage.content;
|
|
@@ -2815,7 +2829,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2815
2829
|
|
|
2816
2830
|
toolCallsExecuted += 1;
|
|
2817
2831
|
|
|
2818
|
-
// Check if we've exceeded the max tool calls limit
|
|
2819
2832
|
if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
|
|
2820
2833
|
logger.error(
|
|
2821
2834
|
{
|
|
@@ -2913,7 +2926,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2913
2926
|
|
|
2914
2927
|
cleanPayload.messages.push(toolMessage);
|
|
2915
2928
|
|
|
2916
|
-
// Convert to Anthropic format for session storage
|
|
2917
2929
|
let sessionToolResultContent;
|
|
2918
2930
|
if (providerType === "azure-anthropic") {
|
|
2919
2931
|
// Azure Anthropic already has content in correct format
|
|
@@ -3009,7 +3021,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3009
3021
|
`Tool call loop detected - same tool called ${loopThreshold} times with identical/similar parameters`,
|
|
3010
3022
|
);
|
|
3011
3023
|
|
|
3012
|
-
// Inject warning message to model
|
|
3013
3024
|
loopWarningInjected = true;
|
|
3014
3025
|
const warningMessage = {
|
|
3015
3026
|
role: "user",
|
|
@@ -3107,7 +3118,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3107
3118
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3108
3119
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3109
3120
|
}
|
|
3110
|
-
} else if (actualProvider === "openrouter") {
|
|
3121
|
+
} else if (actualProvider === "openrouter" || actualProvider === "edenai") {
|
|
3111
3122
|
const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils");
|
|
3112
3123
|
|
|
3113
3124
|
// Validate OpenRouter response has choices array before conversion
|
|
@@ -3246,7 +3257,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3246
3257
|
};
|
|
3247
3258
|
}
|
|
3248
3259
|
|
|
3249
|
-
// Log OpenAI raw response
|
|
3250
3260
|
logger.info({
|
|
3251
3261
|
hasChoices: !!databricksResponse.json?.choices,
|
|
3252
3262
|
choiceCount: databricksResponse.json?.choices?.length || 0,
|
|
@@ -3296,7 +3306,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3296
3306
|
};
|
|
3297
3307
|
}
|
|
3298
3308
|
|
|
3299
|
-
// Log llama.cpp raw response
|
|
3300
3309
|
logger.info({
|
|
3301
3310
|
hasChoices: !!databricksResponse.json?.choices,
|
|
3302
3311
|
choiceCount: databricksResponse.json?.choices?.length || 0,
|
|
@@ -3321,10 +3330,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3321
3330
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3322
3331
|
} else if (actualProvider === "zai") {
|
|
3323
3332
|
// Z.AI responses are already converted to Anthropic format in invokeZai
|
|
3324
|
-
logger.info({
|
|
3325
|
-
hasJson: !!databricksResponse.json,
|
|
3326
|
-
jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 200),
|
|
3327
|
-
}, "=== ZAI ORCHESTRATOR DEBUG ===");
|
|
3328
3333
|
anthropicPayload = databricksResponse.json;
|
|
3329
3334
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3330
3335
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
@@ -3337,10 +3342,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3337
3342
|
}
|
|
3338
3343
|
} else if (actualProvider === "moonshot") {
|
|
3339
3344
|
// Moonshot responses are already converted to Anthropic format in invokeMoonshot
|
|
3340
|
-
logger.info({
|
|
3341
|
-
hasJson: !!databricksResponse.json,
|
|
3342
|
-
jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 300),
|
|
3343
|
-
}, "=== MOONSHOT ORCHESTRATOR DEBUG ===");
|
|
3344
3345
|
anthropicPayload = databricksResponse.json;
|
|
3345
3346
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3346
3347
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
@@ -3351,6 +3352,12 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3351
3352
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3352
3353
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3353
3354
|
}
|
|
3355
|
+
} else if (databricksResponse.json?.type === "message" && Array.isArray(databricksResponse.json?.content)) {
|
|
3356
|
+
// Shape-detected: already Anthropic (some clients convert upstream).
|
|
3357
|
+
// Re-converting via toAnthropicResponse reads the absent choices[]
|
|
3358
|
+
// and empties the content.
|
|
3359
|
+
anthropicPayload = databricksResponse.json;
|
|
3360
|
+
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3354
3361
|
} else {
|
|
3355
3362
|
anthropicPayload = toAnthropicResponse(
|
|
3356
3363
|
databricksResponse.json,
|
|
@@ -3366,7 +3373,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3366
3373
|
(item) => item.type === "text" && needsWebFallback(item.text),
|
|
3367
3374
|
);
|
|
3368
3375
|
|
|
3369
|
-
|
|
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) {
|
|
3370
3380
|
if (providerType === "azure-anthropic") {
|
|
3371
3381
|
anthropicPayload.content.push({
|
|
3372
3382
|
type: "text",
|
|
@@ -3486,7 +3496,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3486
3496
|
|
|
3487
3497
|
cleanPayload.messages.push(assistantToolMessage);
|
|
3488
3498
|
|
|
3489
|
-
// Convert to Anthropic format for session storage
|
|
3490
3499
|
let sessionFallbackContent;
|
|
3491
3500
|
if (providerType === "azure-anthropic") {
|
|
3492
3501
|
// Already in Anthropic format
|
|
@@ -3501,7 +3510,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3501
3510
|
});
|
|
3502
3511
|
}
|
|
3503
3512
|
|
|
3504
|
-
// Add tool_use blocks from tool_calls
|
|
3505
3513
|
if (Array.isArray(assistantToolMessage.tool_calls)) {
|
|
3506
3514
|
for (const tc of assistantToolMessage.tool_calls) {
|
|
3507
3515
|
const func = tc.function || {};
|
|
@@ -3555,7 +3563,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3555
3563
|
|
|
3556
3564
|
cleanPayload.messages.push(toolResultMessage);
|
|
3557
3565
|
|
|
3558
|
-
// Convert to Anthropic format for session storage
|
|
3559
3566
|
let sessionFallbackToolResult;
|
|
3560
3567
|
if (providerType === "azure-anthropic") {
|
|
3561
3568
|
// Already in Anthropic format
|
|
@@ -3589,7 +3596,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3589
3596
|
|
|
3590
3597
|
toolCallsExecuted += 1;
|
|
3591
3598
|
|
|
3592
|
-
// Check if we've exceeded the max tool calls limit
|
|
3593
3599
|
if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
|
|
3594
3600
|
logger.error(
|
|
3595
3601
|
{
|
|
@@ -3641,6 +3647,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3641
3647
|
provider: databricksResponse.routingDecision.provider,
|
|
3642
3648
|
model: databricksResponse.routingDecision.model,
|
|
3643
3649
|
tier: databricksResponse.routingDecision.tier,
|
|
3650
|
+
score: databricksResponse.routingDecision.score ?? null,
|
|
3644
3651
|
};
|
|
3645
3652
|
}
|
|
3646
3653
|
|
|
@@ -3859,11 +3866,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
3859
3866
|
|
|
3860
3867
|
// === TOOL LOOP GUARD (EARLY CHECK) ===
|
|
3861
3868
|
// Check BEFORE sanitization since sanitizePayload removes conversation history
|
|
3862
|
-
// All providers use threshold 2 to catch loops early
|
|
3863
|
-
const providerType = config.modelProvider?.type ?? "databricks";
|
|
3864
|
-
const toolLoopThreshold = 2;
|
|
3865
|
-
const { toolResultCount, toolUseCount } = countToolCallsInHistory(payload?.messages);
|
|
3866
|
-
|
|
3867
3869
|
const executionMode = config.toolExecutionMode || "server";
|
|
3868
3870
|
const isClientMode = executionMode === "client" || executionMode === "passthrough";
|
|
3869
3871
|
|
|
@@ -4009,7 +4011,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4009
4011
|
const cleanPayload = sanitizePayload(payload);
|
|
4010
4012
|
pTimer.mark("sanitizePayload");
|
|
4011
4013
|
|
|
4012
|
-
// Proactively load tools based on prompt content (lazy loading)
|
|
4013
4014
|
try {
|
|
4014
4015
|
const { loaded } = lazyLoader.ensureToolsForPrompt(cleanPayload.messages);
|
|
4015
4016
|
if (loaded.length > 0) {
|
|
@@ -4039,8 +4040,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4039
4040
|
cacheKey = key;
|
|
4040
4041
|
if (entry?.value) {
|
|
4041
4042
|
try {
|
|
4042
|
-
|
|
4043
|
-
cachedResponse = await asyncClone(entry.value);
|
|
4043
|
+
cachedResponse = structuredClone(entry.value);
|
|
4044
4044
|
} catch {
|
|
4045
4045
|
cachedResponse = entry.value;
|
|
4046
4046
|
}
|
|
@@ -4048,11 +4048,14 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4048
4048
|
}
|
|
4049
4049
|
|
|
4050
4050
|
if (cachedResponse) {
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4051
|
+
// Same shape guard as the live path: cached json may already be Anthropic.
|
|
4052
|
+
const anthropicPayload = (cachedResponse.json?.type === "message" && Array.isArray(cachedResponse.json?.content))
|
|
4053
|
+
? cachedResponse.json
|
|
4054
|
+
: toAnthropicResponse(
|
|
4055
|
+
cachedResponse.json,
|
|
4056
|
+
requestedModel,
|
|
4057
|
+
wantsThinking,
|
|
4058
|
+
);
|
|
4056
4059
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
4057
4060
|
|
|
4058
4061
|
const promptTokens = cachedResponse.json?.usage?.prompt_tokens ?? 0;
|
|
@@ -4069,6 +4072,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4069
4072
|
provider: cachedResponse.routingDecision.provider,
|
|
4070
4073
|
model: cachedResponse.routingDecision.model,
|
|
4071
4074
|
tier: cachedResponse.routingDecision.tier,
|
|
4075
|
+
score: cachedResponse.routingDecision.score ?? null,
|
|
4072
4076
|
};
|
|
4073
4077
|
}
|
|
4074
4078
|
|
|
@@ -4095,7 +4099,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4095
4099
|
};
|
|
4096
4100
|
}
|
|
4097
4101
|
|
|
4098
|
-
// Semantic cache lookup (fuzzy matching based on embedding similarity)
|
|
4099
4102
|
let semanticLookupResult = null;
|
|
4100
4103
|
const semanticCache = getSemanticCache();
|
|
4101
4104
|
if (semanticCache.isEnabled()) {
|
|
@@ -4123,7 +4126,12 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4123
4126
|
|
|
4124
4127
|
return {
|
|
4125
4128
|
status: 200,
|
|
4126
|
-
|
|
4129
|
+
// Spread so the marker never mutates the stored cache entry.
|
|
4130
|
+
// Anthropic clients ignore unknown top-level fields; benchmarks
|
|
4131
|
+
// and dashboards use this to attribute cache hits honestly —
|
|
4132
|
+
// previously a hit was indistinguishable from a full-price call
|
|
4133
|
+
// in the response body (usage echoed the cached values).
|
|
4134
|
+
body: { ...cachedBody, lynkr_semantic_cache: { hit: true, similarity: semanticLookupResult.similarity ?? null } },
|
|
4127
4135
|
terminationReason: "completion",
|
|
4128
4136
|
};
|
|
4129
4137
|
}
|
|
@@ -4132,9 +4140,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4132
4140
|
}
|
|
4133
4141
|
}
|
|
4134
4142
|
|
|
4135
|
-
// NOTE: Tool loop guard moved to BEFORE sanitizePayload() since sanitization
|
|
4136
|
-
// removes conversation history (consecutive same-role messages)
|
|
4137
|
-
|
|
4138
4143
|
pTimer.mark("preAgentLoop");
|
|
4139
4144
|
const loopResult = await runAgentLoop({
|
|
4140
4145
|
cleanPayload,
|
|
@@ -4150,7 +4155,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4150
4155
|
pTimer.mark("agentLoopDone");
|
|
4151
4156
|
pTimer.done();
|
|
4152
4157
|
|
|
4153
|
-
// Store successful responses in semantic cache for future fuzzy matching
|
|
4154
4158
|
if (semanticCache.isEnabled() && semanticLookupResult && !semanticLookupResult.hit) {
|
|
4155
4159
|
if (loopResult.response?.status === 200 && loopResult.response?.body) {
|
|
4156
4160
|
try {
|
|
@@ -4164,8 +4168,39 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4164
4168
|
return loopResult.response;
|
|
4165
4169
|
}
|
|
4166
4170
|
|
|
4171
|
+
/**
|
|
4172
|
+
* Trim an over-long agent-loop conversation while ALWAYS preserving the
|
|
4173
|
+
* current task: the latest user message with real typed text. Keeps the
|
|
4174
|
+
* 2-message head (opening context), the task message (re-inserted if it
|
|
4175
|
+
* would fall in the trimmed middle), and the most recent tail. Boundary
|
|
4176
|
+
* orphans (tool_results whose tool_use was trimmed) are handled by the
|
|
4177
|
+
* converters' existing orphan-droppers.
|
|
4178
|
+
*/
|
|
4179
|
+
function trimLoopMessages(messages, max) {
|
|
4180
|
+
const { extractCleanUserText } = require("../routing/intent-score");
|
|
4181
|
+
let taskIdx = -1;
|
|
4182
|
+
for (let i = messages.length - 1; i >= 2; i--) {
|
|
4183
|
+
if (messages[i]?.role === "user" && extractCleanUserText({ messages: [messages[i]] })) {
|
|
4184
|
+
taskIdx = i;
|
|
4185
|
+
break;
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
const head = messages.slice(0, 2);
|
|
4189
|
+
const keepTail = max - head.length - 1;
|
|
4190
|
+
const tailStart = messages.length - keepTail;
|
|
4191
|
+
const taskMsg = taskIdx >= 2 && taskIdx < tailStart ? [messages[taskIdx]] : [];
|
|
4192
|
+
const trimmed = [...head, ...taskMsg, ...messages.slice(tailStart)];
|
|
4193
|
+
logger.debug(
|
|
4194
|
+
{ trimmed: messages.length - trimmed.length, remaining: trimmed.length, taskKept: taskMsg.length > 0 || taskIdx >= tailStart || taskIdx < 2 },
|
|
4195
|
+
"Trimmed intermediate messages to prevent memory growth",
|
|
4196
|
+
);
|
|
4197
|
+
return trimmed;
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4167
4200
|
module.exports = {
|
|
4168
4201
|
processMessage,
|
|
4169
4202
|
// Exported for unit testing of response-metadata conversion.
|
|
4170
4203
|
toAnthropicResponse,
|
|
4204
|
+
// Exported for unit testing of loop trimming (task-preservation contract).
|
|
4205
|
+
trimLoopMessages,
|
|
4171
4206
|
};
|