lynkr 9.7.2 → 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/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- 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 +459 -146
- 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 +144 -88
- 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 +298 -10
- 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
|
|
|
@@ -1111,7 +1113,13 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
|
|
|
1111
1113
|
id: openai.id ?? `msg_${Date.now()}`,
|
|
1112
1114
|
type: "message",
|
|
1113
1115
|
role: "assistant",
|
|
1114
|
-
model
|
|
1116
|
+
// Prefer the model the provider actually served with; fall back to the
|
|
1117
|
+
// requested model only when the provider omits it. Mirrors the direct
|
|
1118
|
+
// (non-tool) path at `databricksResponse.json.model || requestedModel`, so
|
|
1119
|
+
// tool-call responses no longer report a stale/aliased client-request model.
|
|
1120
|
+
model: (typeof openai?.model === "string" && openai.model.trim())
|
|
1121
|
+
? openai.model
|
|
1122
|
+
: requestedModel,
|
|
1115
1123
|
content: contentItems,
|
|
1116
1124
|
stop_reason:
|
|
1117
1125
|
choice?.finish_reason === "stop"
|
|
@@ -1123,8 +1131,11 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
|
|
|
1123
1131
|
: choice?.finish_reason ?? "end_turn",
|
|
1124
1132
|
stop_sequence: null,
|
|
1125
1133
|
usage: {
|
|
1126
|
-
|
|
1127
|
-
output_tokens
|
|
1134
|
+
// Accept both OpenAI (prompt_tokens/completion_tokens) and
|
|
1135
|
+
// already-Anthropic (input_tokens/output_tokens) usage shapes so token
|
|
1136
|
+
// counts survive regardless of which provider/converter produced them.
|
|
1137
|
+
input_tokens: usage.prompt_tokens ?? usage.input_tokens ?? 0,
|
|
1138
|
+
output_tokens: usage.completion_tokens ?? usage.output_tokens ?? 0,
|
|
1128
1139
|
cache_creation_input_tokens: 0,
|
|
1129
1140
|
cache_read_input_tokens: 0,
|
|
1130
1141
|
},
|
|
@@ -1309,8 +1320,8 @@ function sanitizePayload(payload) {
|
|
|
1309
1320
|
if (Array.isArray(clean.tools) && clean.tools.length > 0) {
|
|
1310
1321
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1311
1322
|
}
|
|
1312
|
-
} else if (providerType === "openrouter") {
|
|
1313
|
-
// OpenRouter
|
|
1323
|
+
} else if (providerType === "openrouter" || providerType === "edenai") {
|
|
1324
|
+
// OpenRouter / Eden AI (OpenAI-compatible) support tools - keep them as-is.
|
|
1314
1325
|
// Tools are already in Anthropic format and will be converted by openrouter-utils
|
|
1315
1326
|
if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
|
|
1316
1327
|
delete clean.tools;
|
|
@@ -1321,7 +1332,6 @@ function sanitizePayload(payload) {
|
|
|
1321
1332
|
if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
|
|
1322
1333
|
delete clean.tools;
|
|
1323
1334
|
} else {
|
|
1324
|
-
// Ensure tools are in Anthropic format
|
|
1325
1335
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1326
1336
|
}
|
|
1327
1337
|
} else if (providerType === "vertex") {
|
|
@@ -1339,6 +1349,15 @@ function sanitizePayload(payload) {
|
|
|
1339
1349
|
} else {
|
|
1340
1350
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1341
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
|
+
}
|
|
1342
1361
|
} else if (Array.isArray(clean.tools)) {
|
|
1343
1362
|
// Unknown provider - remove tools for safety
|
|
1344
1363
|
delete clean.tools;
|
|
@@ -1422,9 +1441,14 @@ function sanitizePayload(payload) {
|
|
|
1422
1441
|
|
|
1423
1442
|
// Optional TOON conversion for large JSON message payloads (prompt context only).
|
|
1424
1443
|
// Run this BEFORE message coalescing to preserve parseable JSON boundaries.
|
|
1425
|
-
|
|
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
|
+
}
|
|
1426
1450
|
|
|
1427
|
-
//
|
|
1451
|
+
// Handle consecutive messages with the same role (causes llama.cpp 400 error)
|
|
1428
1452
|
// Strategy: Merge consecutive same-role messages, but NEVER merge messages
|
|
1429
1453
|
// that contain tool_use or tool_result blocks — they must stay intact for
|
|
1430
1454
|
// the provider's tool-call protocol.
|
|
@@ -1631,17 +1655,21 @@ async function runAgentLoop({
|
|
|
1631
1655
|
"Agent loop step",
|
|
1632
1656
|
);
|
|
1633
1657
|
|
|
1634
|
-
// Trim
|
|
1635
|
-
//
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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);
|
|
1645
1673
|
}
|
|
1646
1674
|
|
|
1647
1675
|
// Debug: Log payload before sending to Azure
|
|
@@ -1708,7 +1736,6 @@ async function runAgentLoop({
|
|
|
1708
1736
|
memoriesRetrieved: relevantMemories.length,
|
|
1709
1737
|
}, 'Injecting long-term memories into context');
|
|
1710
1738
|
|
|
1711
|
-
// Inject memories into system prompt
|
|
1712
1739
|
const injectedSystem = memoryRetriever.injectMemoriesIntoSystem(
|
|
1713
1740
|
cleanPayload.system,
|
|
1714
1741
|
relevantMemories,
|
|
@@ -1915,9 +1942,9 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1915
1942
|
// Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
|
|
1916
1943
|
// analogous gate.
|
|
1917
1944
|
const headroomProviderMap = {
|
|
1918
|
-
'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
|
|
1919
|
-
'openai': new Set(['azure-openai', 'openai', 'openrouter']),
|
|
1920
|
-
'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']),
|
|
1921
1948
|
};
|
|
1922
1949
|
const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
|
|
1923
1950
|
const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
|
|
@@ -1976,7 +2003,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1976
2003
|
// Generate correlation ID for request/response pairing
|
|
1977
2004
|
const correlationId = `req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
|
|
1978
2005
|
|
|
1979
|
-
// Log LLM request before invocation
|
|
1980
2006
|
if (auditLogger.enabled) {
|
|
1981
2007
|
auditLogger.logLlmRequest({
|
|
1982
2008
|
correlationId,
|
|
@@ -2040,6 +2066,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2040
2066
|
'bedrock',
|
|
2041
2067
|
'vertex',
|
|
2042
2068
|
'openrouter',
|
|
2069
|
+
'edenai',
|
|
2043
2070
|
'ollama',
|
|
2044
2071
|
'openai',
|
|
2045
2072
|
'azure-openai',
|
|
@@ -2098,7 +2125,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2098
2125
|
throw modelError;
|
|
2099
2126
|
}
|
|
2100
2127
|
|
|
2101
|
-
// Extract and log actual token usage
|
|
2102
2128
|
const actualUsage = databricksResponse.ok && config.tokenTracking?.enabled !== false
|
|
2103
2129
|
? tokens.extractUsageFromResponse(databricksResponse.json)
|
|
2104
2130
|
: null;
|
|
@@ -2112,12 +2138,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2112
2138
|
}
|
|
2113
2139
|
}
|
|
2114
2140
|
|
|
2115
|
-
// Log LLM response after invocation
|
|
2116
2141
|
if (auditLogger.enabled) {
|
|
2117
2142
|
const latencyMs = Date.now() - start;
|
|
2118
2143
|
|
|
2119
2144
|
if (databricksResponse.stream) {
|
|
2120
|
-
// Log streaming response (no content, just metadata)
|
|
2121
2145
|
auditLogger.logLlmResponse({
|
|
2122
2146
|
correlationId,
|
|
2123
2147
|
sessionId: session?.id ?? null,
|
|
@@ -2130,7 +2154,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2130
2154
|
streamingNote: 'Content streamed directly to client, not captured in audit log',
|
|
2131
2155
|
});
|
|
2132
2156
|
} else if (databricksResponse.ok && databricksResponse.json) {
|
|
2133
|
-
// Log successful non-streaming response
|
|
2134
2157
|
const message = databricksResponse.json;
|
|
2135
2158
|
const assistantMessage = message.content ?? message.choices?.[0]?.message;
|
|
2136
2159
|
|
|
@@ -2149,7 +2172,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2149
2172
|
status: databricksResponse.status,
|
|
2150
2173
|
});
|
|
2151
2174
|
} else {
|
|
2152
|
-
// Log error response
|
|
2153
2175
|
auditLogger.logLlmResponse({
|
|
2154
2176
|
correlationId,
|
|
2155
2177
|
sessionId: session?.id ?? null,
|
|
@@ -2324,7 +2346,11 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2324
2346
|
? (databricksResponse.json?.content ?? []).some(b => b?.type === "text" && String(b.text || "").trim().length > 0)
|
|
2325
2347
|
: (typeof message.content === "string" && message.content.trim().length > 0);
|
|
2326
2348
|
|
|
2327
|
-
|
|
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) {
|
|
2328
2354
|
logger.info({
|
|
2329
2355
|
sessionId: session?.id ?? null,
|
|
2330
2356
|
step: steps,
|
|
@@ -2353,7 +2379,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2353
2379
|
const contentBlocks = [];
|
|
2354
2380
|
let toolCallIdx = 0;
|
|
2355
2381
|
|
|
2356
|
-
// Add text content if present
|
|
2357
2382
|
if (message.content && typeof message.content === 'string' && message.content.trim()) {
|
|
2358
2383
|
contentBlocks.push({
|
|
2359
2384
|
type: "text",
|
|
@@ -2361,12 +2386,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2361
2386
|
});
|
|
2362
2387
|
}
|
|
2363
2388
|
|
|
2364
|
-
// Add tool_use blocks from tool_calls
|
|
2365
2389
|
for (const toolCall of toolCalls) {
|
|
2366
2390
|
const func = toolCall.function || {};
|
|
2367
2391
|
let input = {};
|
|
2368
2392
|
|
|
2369
|
-
// Parse arguments string to object
|
|
2370
2393
|
if (func.arguments) {
|
|
2371
2394
|
try {
|
|
2372
2395
|
input = typeof func.arguments === "string"
|
|
@@ -2439,15 +2462,18 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2439
2462
|
|
|
2440
2463
|
cleanPayload.messages.push(assistantToolMessage);
|
|
2441
2464
|
|
|
2442
|
-
// Check if tool execution should happen on client side
|
|
2443
2465
|
const executionMode = config.toolExecutionMode || "server";
|
|
2444
2466
|
|
|
2445
|
-
//
|
|
2446
|
-
//
|
|
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.
|
|
2447
2471
|
const serverSideToolCalls = [];
|
|
2448
2472
|
const clientSideToolCalls = [];
|
|
2449
2473
|
|
|
2450
|
-
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"]);
|
|
2451
2477
|
|
|
2452
2478
|
for (const call of toolCalls) {
|
|
2453
2479
|
const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
|
|
@@ -2599,7 +2625,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2599
2625
|
}, "Executing multiple Task tools in parallel");
|
|
2600
2626
|
|
|
2601
2627
|
try {
|
|
2602
|
-
// Execute all Task tools in parallel
|
|
2603
2628
|
const taskExecutions = await Promise.all(
|
|
2604
2629
|
taskCalls.map(({ call }) => executeToolCall(call, {
|
|
2605
2630
|
session,
|
|
@@ -2609,7 +2634,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2609
2634
|
}))
|
|
2610
2635
|
);
|
|
2611
2636
|
|
|
2612
|
-
// Process results and add to messages
|
|
2613
2637
|
taskExecutions.forEach((execution, index) => {
|
|
2614
2638
|
const call = taskCalls[index].call;
|
|
2615
2639
|
toolCallsExecuted += 1;
|
|
@@ -2773,7 +2797,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2773
2797
|
|
|
2774
2798
|
cleanPayload.messages.push(toolResultMessage);
|
|
2775
2799
|
|
|
2776
|
-
// Convert to Anthropic format for session storage
|
|
2777
2800
|
let sessionToolResult;
|
|
2778
2801
|
if (providerType === "azure-anthropic") {
|
|
2779
2802
|
sessionToolResult = toolResultMessage.content;
|
|
@@ -2806,7 +2829,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2806
2829
|
|
|
2807
2830
|
toolCallsExecuted += 1;
|
|
2808
2831
|
|
|
2809
|
-
// Check if we've exceeded the max tool calls limit
|
|
2810
2832
|
if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
|
|
2811
2833
|
logger.error(
|
|
2812
2834
|
{
|
|
@@ -2904,7 +2926,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2904
2926
|
|
|
2905
2927
|
cleanPayload.messages.push(toolMessage);
|
|
2906
2928
|
|
|
2907
|
-
// Convert to Anthropic format for session storage
|
|
2908
2929
|
let sessionToolResultContent;
|
|
2909
2930
|
if (providerType === "azure-anthropic") {
|
|
2910
2931
|
// Azure Anthropic already has content in correct format
|
|
@@ -3000,7 +3021,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3000
3021
|
`Tool call loop detected - same tool called ${loopThreshold} times with identical/similar parameters`,
|
|
3001
3022
|
);
|
|
3002
3023
|
|
|
3003
|
-
// Inject warning message to model
|
|
3004
3024
|
loopWarningInjected = true;
|
|
3005
3025
|
const warningMessage = {
|
|
3006
3026
|
role: "user",
|
|
@@ -3098,7 +3118,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3098
3118
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3099
3119
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3100
3120
|
}
|
|
3101
|
-
} else if (actualProvider === "openrouter") {
|
|
3121
|
+
} else if (actualProvider === "openrouter" || actualProvider === "edenai") {
|
|
3102
3122
|
const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils");
|
|
3103
3123
|
|
|
3104
3124
|
// Validate OpenRouter response has choices array before conversion
|
|
@@ -3237,7 +3257,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3237
3257
|
};
|
|
3238
3258
|
}
|
|
3239
3259
|
|
|
3240
|
-
// Log OpenAI raw response
|
|
3241
3260
|
logger.info({
|
|
3242
3261
|
hasChoices: !!databricksResponse.json?.choices,
|
|
3243
3262
|
choiceCount: databricksResponse.json?.choices?.length || 0,
|
|
@@ -3287,7 +3306,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3287
3306
|
};
|
|
3288
3307
|
}
|
|
3289
3308
|
|
|
3290
|
-
// Log llama.cpp raw response
|
|
3291
3309
|
logger.info({
|
|
3292
3310
|
hasChoices: !!databricksResponse.json?.choices,
|
|
3293
3311
|
choiceCount: databricksResponse.json?.choices?.length || 0,
|
|
@@ -3312,10 +3330,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3312
3330
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3313
3331
|
} else if (actualProvider === "zai") {
|
|
3314
3332
|
// Z.AI responses are already converted to Anthropic format in invokeZai
|
|
3315
|
-
logger.info({
|
|
3316
|
-
hasJson: !!databricksResponse.json,
|
|
3317
|
-
jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 200),
|
|
3318
|
-
}, "=== ZAI ORCHESTRATOR DEBUG ===");
|
|
3319
3333
|
anthropicPayload = databricksResponse.json;
|
|
3320
3334
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3321
3335
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
@@ -3328,10 +3342,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3328
3342
|
}
|
|
3329
3343
|
} else if (actualProvider === "moonshot") {
|
|
3330
3344
|
// Moonshot responses are already converted to Anthropic format in invokeMoonshot
|
|
3331
|
-
logger.info({
|
|
3332
|
-
hasJson: !!databricksResponse.json,
|
|
3333
|
-
jsonContent: JSON.stringify(databricksResponse.json?.content)?.substring(0, 300),
|
|
3334
|
-
}, "=== MOONSHOT ORCHESTRATOR DEBUG ===");
|
|
3335
3345
|
anthropicPayload = databricksResponse.json;
|
|
3336
3346
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3337
3347
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
@@ -3342,6 +3352,12 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3342
3352
|
if (Array.isArray(anthropicPayload?.content)) {
|
|
3343
3353
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
3344
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);
|
|
3345
3361
|
} else {
|
|
3346
3362
|
anthropicPayload = toAnthropicResponse(
|
|
3347
3363
|
databricksResponse.json,
|
|
@@ -3357,7 +3373,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3357
3373
|
(item) => item.type === "text" && needsWebFallback(item.text),
|
|
3358
3374
|
);
|
|
3359
3375
|
|
|
3360
|
-
|
|
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) {
|
|
3361
3380
|
if (providerType === "azure-anthropic") {
|
|
3362
3381
|
anthropicPayload.content.push({
|
|
3363
3382
|
type: "text",
|
|
@@ -3477,7 +3496,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3477
3496
|
|
|
3478
3497
|
cleanPayload.messages.push(assistantToolMessage);
|
|
3479
3498
|
|
|
3480
|
-
// Convert to Anthropic format for session storage
|
|
3481
3499
|
let sessionFallbackContent;
|
|
3482
3500
|
if (providerType === "azure-anthropic") {
|
|
3483
3501
|
// Already in Anthropic format
|
|
@@ -3492,7 +3510,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3492
3510
|
});
|
|
3493
3511
|
}
|
|
3494
3512
|
|
|
3495
|
-
// Add tool_use blocks from tool_calls
|
|
3496
3513
|
if (Array.isArray(assistantToolMessage.tool_calls)) {
|
|
3497
3514
|
for (const tc of assistantToolMessage.tool_calls) {
|
|
3498
3515
|
const func = tc.function || {};
|
|
@@ -3546,7 +3563,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3546
3563
|
|
|
3547
3564
|
cleanPayload.messages.push(toolResultMessage);
|
|
3548
3565
|
|
|
3549
|
-
// Convert to Anthropic format for session storage
|
|
3550
3566
|
let sessionFallbackToolResult;
|
|
3551
3567
|
if (providerType === "azure-anthropic") {
|
|
3552
3568
|
// Already in Anthropic format
|
|
@@ -3580,7 +3596,6 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3580
3596
|
|
|
3581
3597
|
toolCallsExecuted += 1;
|
|
3582
3598
|
|
|
3583
|
-
// Check if we've exceeded the max tool calls limit
|
|
3584
3599
|
if (settings.maxToolCallsPerRequest && toolCallsExecuted > settings.maxToolCallsPerRequest) {
|
|
3585
3600
|
logger.error(
|
|
3586
3601
|
{
|
|
@@ -3632,6 +3647,7 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
3632
3647
|
provider: databricksResponse.routingDecision.provider,
|
|
3633
3648
|
model: databricksResponse.routingDecision.model,
|
|
3634
3649
|
tier: databricksResponse.routingDecision.tier,
|
|
3650
|
+
score: databricksResponse.routingDecision.score ?? null,
|
|
3635
3651
|
};
|
|
3636
3652
|
}
|
|
3637
3653
|
|
|
@@ -3850,11 +3866,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
3850
3866
|
|
|
3851
3867
|
// === TOOL LOOP GUARD (EARLY CHECK) ===
|
|
3852
3868
|
// Check BEFORE sanitization since sanitizePayload removes conversation history
|
|
3853
|
-
// All providers use threshold 2 to catch loops early
|
|
3854
|
-
const providerType = config.modelProvider?.type ?? "databricks";
|
|
3855
|
-
const toolLoopThreshold = 2;
|
|
3856
|
-
const { toolResultCount, toolUseCount } = countToolCallsInHistory(payload?.messages);
|
|
3857
|
-
|
|
3858
3869
|
const executionMode = config.toolExecutionMode || "server";
|
|
3859
3870
|
const isClientMode = executionMode === "client" || executionMode === "passthrough";
|
|
3860
3871
|
|
|
@@ -4000,7 +4011,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4000
4011
|
const cleanPayload = sanitizePayload(payload);
|
|
4001
4012
|
pTimer.mark("sanitizePayload");
|
|
4002
4013
|
|
|
4003
|
-
// Proactively load tools based on prompt content (lazy loading)
|
|
4004
4014
|
try {
|
|
4005
4015
|
const { loaded } = lazyLoader.ensureToolsForPrompt(cleanPayload.messages);
|
|
4006
4016
|
if (loaded.length > 0) {
|
|
@@ -4030,8 +4040,7 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4030
4040
|
cacheKey = key;
|
|
4031
4041
|
if (entry?.value) {
|
|
4032
4042
|
try {
|
|
4033
|
-
|
|
4034
|
-
cachedResponse = await asyncClone(entry.value);
|
|
4043
|
+
cachedResponse = structuredClone(entry.value);
|
|
4035
4044
|
} catch {
|
|
4036
4045
|
cachedResponse = entry.value;
|
|
4037
4046
|
}
|
|
@@ -4039,11 +4048,14 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4039
4048
|
}
|
|
4040
4049
|
|
|
4041
4050
|
if (cachedResponse) {
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
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
|
+
);
|
|
4047
4059
|
anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
|
|
4048
4060
|
|
|
4049
4061
|
const promptTokens = cachedResponse.json?.usage?.prompt_tokens ?? 0;
|
|
@@ -4053,6 +4065,17 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4053
4065
|
anthropicPayload.usage.cache_read_input_tokens = promptTokens;
|
|
4054
4066
|
anthropicPayload.usage.cache_creation_input_tokens = 0;
|
|
4055
4067
|
|
|
4068
|
+
// Carry routing metadata on the cache-hit path too, so downstream model
|
|
4069
|
+
// name resolution (OpenClaw) behaves the same as the live loop path.
|
|
4070
|
+
if (cachedResponse.routingDecision) {
|
|
4071
|
+
anthropicPayload._routingMeta = {
|
|
4072
|
+
provider: cachedResponse.routingDecision.provider,
|
|
4073
|
+
model: cachedResponse.routingDecision.model,
|
|
4074
|
+
tier: cachedResponse.routingDecision.tier,
|
|
4075
|
+
score: cachedResponse.routingDecision.score ?? null,
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4056
4079
|
appendTurnToSession(session, {
|
|
4057
4080
|
role: "assistant",
|
|
4058
4081
|
type: "message",
|
|
@@ -4076,7 +4099,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4076
4099
|
};
|
|
4077
4100
|
}
|
|
4078
4101
|
|
|
4079
|
-
// Semantic cache lookup (fuzzy matching based on embedding similarity)
|
|
4080
4102
|
let semanticLookupResult = null;
|
|
4081
4103
|
const semanticCache = getSemanticCache();
|
|
4082
4104
|
if (semanticCache.isEnabled()) {
|
|
@@ -4104,7 +4126,12 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4104
4126
|
|
|
4105
4127
|
return {
|
|
4106
4128
|
status: 200,
|
|
4107
|
-
|
|
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 } },
|
|
4108
4135
|
terminationReason: "completion",
|
|
4109
4136
|
};
|
|
4110
4137
|
}
|
|
@@ -4113,9 +4140,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4113
4140
|
}
|
|
4114
4141
|
}
|
|
4115
4142
|
|
|
4116
|
-
// NOTE: Tool loop guard moved to BEFORE sanitizePayload() since sanitization
|
|
4117
|
-
// removes conversation history (consecutive same-role messages)
|
|
4118
|
-
|
|
4119
4143
|
pTimer.mark("preAgentLoop");
|
|
4120
4144
|
const loopResult = await runAgentLoop({
|
|
4121
4145
|
cleanPayload,
|
|
@@ -4131,7 +4155,6 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4131
4155
|
pTimer.mark("agentLoopDone");
|
|
4132
4156
|
pTimer.done();
|
|
4133
4157
|
|
|
4134
|
-
// Store successful responses in semantic cache for future fuzzy matching
|
|
4135
4158
|
if (semanticCache.isEnabled() && semanticLookupResult && !semanticLookupResult.hit) {
|
|
4136
4159
|
if (loopResult.response?.status === 200 && loopResult.response?.body) {
|
|
4137
4160
|
try {
|
|
@@ -4145,6 +4168,39 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
|
|
|
4145
4168
|
return loopResult.response;
|
|
4146
4169
|
}
|
|
4147
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
|
+
|
|
4148
4200
|
module.exports = {
|
|
4149
4201
|
processMessage,
|
|
4202
|
+
// Exported for unit testing of response-metadata conversion.
|
|
4203
|
+
toAnthropicResponse,
|
|
4204
|
+
// Exported for unit testing of loop trimming (task-preservation contract).
|
|
4205
|
+
trimLoopMessages,
|
|
4150
4206
|
};
|