lynkr 9.5.0 → 9.7.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 +39 -1
- package/bin/cli.js +2 -0
- package/bin/wrap.js +686 -0
- package/package.json +4 -3
- package/scripts/build-knn-index.js +1 -1
- package/scripts/convert-routellm.py +105 -0
- package/src/agents/decomposition/dispatcher.js +185 -0
- package/src/agents/decomposition/gate.js +136 -0
- package/src/agents/decomposition/index.js +183 -0
- package/src/agents/decomposition/model-call.js +75 -0
- package/src/agents/decomposition/planner.js +223 -0
- package/src/agents/decomposition/synthesizer.js +89 -0
- package/src/agents/decomposition/telemetry.js +55 -0
- package/src/api/router.js +694 -21
- package/src/auth-mode.js +116 -0
- package/src/clients/databricks.js +551 -68
- package/src/clients/openrouter-utils.js +6 -29
- package/src/clients/prompt-cache-injection.js +64 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +23 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/orchestrator/index.js +120 -60
- package/src/routing/index.js +31 -4
- package/src/routing/knn-router.js +9 -2
- package/src/routing/model-tiers.js +34 -0
- package/src/routing/tier-fallback.js +91 -0
- package/src/server.js +2 -0
- package/src/tools/decompose.js +91 -0
- package/src/tools/lazy-loader.js +8 -0
package/src/routing/index.js
CHANGED
|
@@ -486,7 +486,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
486
486
|
: null;
|
|
487
487
|
if (queryText) {
|
|
488
488
|
knnResult = await getKnnRouter().query(queryText);
|
|
489
|
-
|
|
489
|
+
// Confidence thresholds (env-configurable; defaults 0.7 high / 0.4 low):
|
|
490
|
+
const KNN_HIGH = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_HIGH) || 0.7;
|
|
491
|
+
const KNN_LOW = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_LOW) || 0.4;
|
|
492
|
+
if (knnResult && knnResult.confidence > KNN_HIGH && knnResult.model && knnResult.model !== selectedModel) {
|
|
490
493
|
// High confidence — trust kNN's model recommendation directly.
|
|
491
494
|
logger.debug({
|
|
492
495
|
from: `${provider}:${selectedModel}`,
|
|
@@ -496,7 +499,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
496
499
|
provider = knnResult.provider;
|
|
497
500
|
selectedModel = knnResult.model;
|
|
498
501
|
method = method + '+knn';
|
|
499
|
-
} else if (knnResult && knnResult.confidence >
|
|
502
|
+
} else if (knnResult && knnResult.confidence > KNN_LOW && knnResult.confidence <= KNN_HIGH) {
|
|
500
503
|
// Ambiguous signal — neighbors are split, we can't trust any single model
|
|
501
504
|
// recommendation. Err on quality: bump the current tier one step up so the
|
|
502
505
|
// request gets a more capable model rather than risking a bad answer from
|
|
@@ -532,10 +535,26 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
532
535
|
// one with the highest estimated UCB score for the current context.
|
|
533
536
|
if (config.routing?.banditEnabled !== false && knnResult && knnResult.model) {
|
|
534
537
|
try {
|
|
535
|
-
// Build candidates: current selection and kNN alternative if different
|
|
538
|
+
// Build candidates: current selection and kNN alternative if different.
|
|
539
|
+
//
|
|
540
|
+
// Tier-aware filter: only treat the kNN suggestion as a real candidate
|
|
541
|
+
// if it matches a (provider, model) combo configured in ANY TIER_*
|
|
542
|
+
// entry. The bandit is allowed to explore freely across the user's
|
|
543
|
+
// configured tiers (e.g. swap a SIMPLE request to the COMPLEX-tier
|
|
544
|
+
// model), but is forbidden from picking a credentialed-but-untiered
|
|
545
|
+
// model (e.g. an Azure OpenAI deployment whose endpoint is set in .env
|
|
546
|
+
// for some other use, but not referenced by any TIER_*). This keeps
|
|
547
|
+
// tier routing as the source of truth for what's eligible while
|
|
548
|
+
// preserving cross-tier bandit exploration.
|
|
536
549
|
const allCandidates = [{ provider, model: selectedModel }];
|
|
537
550
|
if (knnResult.model !== selectedModel) {
|
|
538
|
-
|
|
551
|
+
const configured = require('./model-tiers').getModelTierSelector().getAllConfiguredModels();
|
|
552
|
+
const inConfig = configured.some(
|
|
553
|
+
m => m.provider === knnResult.provider && m.model === knnResult.model
|
|
554
|
+
);
|
|
555
|
+
if (inConfig) {
|
|
556
|
+
allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
|
|
557
|
+
}
|
|
539
558
|
}
|
|
540
559
|
|
|
541
560
|
if (allCandidates.length > 1) {
|
|
@@ -697,6 +716,14 @@ function getRoutingHeaders(decision) {
|
|
|
697
716
|
headers['X-Lynkr-Cost-Optimized'] = 'true';
|
|
698
717
|
}
|
|
699
718
|
|
|
719
|
+
// Tier-aware fallback surfacing (never silent).
|
|
720
|
+
if (decision.fallback) {
|
|
721
|
+
headers['X-Lynkr-Fallback'] = 'true';
|
|
722
|
+
if (decision.fromTier) headers['X-Lynkr-Fallback-From-Tier'] = decision.fromTier;
|
|
723
|
+
if (decision.servedTier) headers['X-Lynkr-Served-Tier'] = decision.servedTier;
|
|
724
|
+
if (decision.fallbackDirection) headers['X-Lynkr-Fallback-Direction'] = decision.fallbackDirection;
|
|
725
|
+
}
|
|
726
|
+
|
|
700
727
|
if (decision.risk?.level) {
|
|
701
728
|
headers['X-Lynkr-Risk'] = decision.risk.level;
|
|
702
729
|
const hits = Array.from(new Set([
|
|
@@ -29,7 +29,10 @@ const META_FILE = path.join(INDEX_DIR, 'meta.json');
|
|
|
29
29
|
const MAX_ELEMENTS = 50000;
|
|
30
30
|
const DIM = 768; // nomic-embed-text default
|
|
31
31
|
const K = 10;
|
|
32
|
-
|
|
32
|
+
// Default 1000 is a safety floor for quality; override via env when you
|
|
33
|
+
// want to activate kNN with less data (e.g. bootstrapping from your own
|
|
34
|
+
// telemetry before reaching 1k entries).
|
|
35
|
+
const MIN_INDEX_SIZE = Number.parseInt(process.env.LYNKR_KNN_MIN_INDEX_SIZE, 10) || 1000;
|
|
33
36
|
|
|
34
37
|
let _hnsw = null;
|
|
35
38
|
let _hnswLoaded = false;
|
|
@@ -72,7 +75,11 @@ class KnnRouter {
|
|
|
72
75
|
this.meta = metaData.entries || [];
|
|
73
76
|
this.size = this.meta.length;
|
|
74
77
|
this.index = new hnsw.HierarchicalNSW('cosine', this.dim);
|
|
75
|
-
|
|
78
|
+
// hnswlib-node v3 API: readIndexSync(filename, allowReplaceDeleted=false).
|
|
79
|
+
// (Earlier Lynkr code passed MAX_ELEMENTS here — wrong type, threw on load.)
|
|
80
|
+
this.index.readIndexSync(INDEX_FILE, false);
|
|
81
|
+
// resize if needed so we can keep adding up to MAX_ELEMENTS
|
|
82
|
+
try { this.index.resizeIndex(MAX_ELEMENTS); } catch (_) {}
|
|
76
83
|
this.ready = true;
|
|
77
84
|
logger.info({ size: this.size, dim: this.dim }, '[KnnRouter] Index loaded');
|
|
78
85
|
return true;
|
|
@@ -258,6 +258,40 @@ class ModelTierSelector {
|
|
|
258
258
|
};
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Return every {provider, model} combo configured for a tier.
|
|
263
|
+
* Today TIER_* parses to a single provider:model, so this returns at most
|
|
264
|
+
* one entry. Kept as an array so callers don't have to change when
|
|
265
|
+
* multi-model tier syntax is added (e.g. TIER_SIMPLE=ollama:m1,ollama:m2).
|
|
266
|
+
*/
|
|
267
|
+
getModelsForTier(tier) {
|
|
268
|
+
const tierConfig = config.modelTiers?.[tier];
|
|
269
|
+
if (!tierConfig) return [];
|
|
270
|
+
const parsed = this._parseTierConfig(tierConfig);
|
|
271
|
+
return parsed ? [{ provider: parsed.provider, model: parsed.model }] : [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Return the union of every {provider, model} configured across all tiers,
|
|
276
|
+
* deduped. Used by the bandit-candidate filter to constrain exploration to
|
|
277
|
+
* the user's stated tier preferences — the bandit may pick any combo the
|
|
278
|
+
* user has configured for any tier, but never a model that isn't in any
|
|
279
|
+
* TIER_* entry (even if its credentials happen to be set).
|
|
280
|
+
*/
|
|
281
|
+
getAllConfiguredModels() {
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const tier of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) {
|
|
285
|
+
for (const m of this.getModelsForTier(tier)) {
|
|
286
|
+
const key = `${m.provider}:${m.model}`;
|
|
287
|
+
if (seen.has(key)) continue;
|
|
288
|
+
seen.add(key);
|
|
289
|
+
out.push(m);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
261
295
|
/**
|
|
262
296
|
* Parse tier config string (format: provider:model)
|
|
263
297
|
* Examples: "ollama:llama3.2", "azure-openai:gpt-5.2-chat", "openai:gpt-4o"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-aware fallback chain (escalate-then-demote).
|
|
3
|
+
*
|
|
4
|
+
* When a tier's provider fails, prefer a MORE capable tier first (climb toward
|
|
5
|
+
* REASONING), and only if every higher tier is also unavailable do we fall
|
|
6
|
+
* downward — all the way to the local SIMPLE tier as a last resort. This biases
|
|
7
|
+
* for correctness/availability over cost, matching a conservative routing policy.
|
|
8
|
+
*
|
|
9
|
+
* Example ladder: SIMPLE → MEDIUM → COMPLEX → REASONING
|
|
10
|
+
* - COMPLEX fails → [REASONING, MEDIUM, SIMPLE]
|
|
11
|
+
* - REASONING fails → [COMPLEX, MEDIUM, SIMPLE]
|
|
12
|
+
* - MEDIUM fails → [COMPLEX, REASONING, SIMPLE]
|
|
13
|
+
*
|
|
14
|
+
* Pure and dependency-injectable so it can be unit-tested without real providers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const logger = require("../logger");
|
|
18
|
+
|
|
19
|
+
const TIER_ORDER = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
|
20
|
+
|
|
21
|
+
/** Default availability check: a provider is unavailable if its circuit is OPEN. */
|
|
22
|
+
function defaultIsProviderAvailable(provider) {
|
|
23
|
+
try {
|
|
24
|
+
const { getCircuitBreakerRegistry } = require("../clients/circuit-breaker");
|
|
25
|
+
const registry = getCircuitBreakerRegistry();
|
|
26
|
+
const all = typeof registry.getAll === "function" ? registry.getAll() : [];
|
|
27
|
+
const entry = Array.isArray(all) ? all.find((b) => b.name === provider) : null;
|
|
28
|
+
return !(entry && entry.state === "OPEN");
|
|
29
|
+
} catch {
|
|
30
|
+
return true; // fail open — never block fallback on a health-check error
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve a tier name to { tier, provider, model }, or null if not configured. */
|
|
35
|
+
function resolveTier(tier, selector) {
|
|
36
|
+
try {
|
|
37
|
+
const sel = selector || require("./model-tiers").getModelTierSelector();
|
|
38
|
+
const r = sel.selectModel(tier);
|
|
39
|
+
if (!r || !r.provider || !r.model) return null;
|
|
40
|
+
return { tier, provider: r.provider, model: r.model };
|
|
41
|
+
} catch {
|
|
42
|
+
return null; // tier not configured (TIER_<X> unset) — skip it
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the escalate-then-demote fallback chain for a failed tier.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} currentTier - the tier whose provider just failed
|
|
50
|
+
* @param {Object} [opts]
|
|
51
|
+
* @param {Object} [opts.selector] - model-tiers selector (injected for tests)
|
|
52
|
+
* @param {Function} [opts.isProviderAvailable] - (provider) => boolean (injected for tests)
|
|
53
|
+
* @returns {Array<{tier,provider,model,demotedFrom,direction}>} ordered candidates
|
|
54
|
+
*/
|
|
55
|
+
function getFallbackChain(currentTier, opts = {}) {
|
|
56
|
+
const isAvailable = opts.isProviderAvailable || defaultIsProviderAvailable;
|
|
57
|
+
const idx = TIER_ORDER.indexOf(currentTier);
|
|
58
|
+
if (idx === -1) return [];
|
|
59
|
+
|
|
60
|
+
const higher = TIER_ORDER.slice(idx + 1); // ascending toward REASONING
|
|
61
|
+
const lower = TIER_ORDER.slice(0, idx).reverse(); // descending toward SIMPLE
|
|
62
|
+
const ordered = [...higher, ...lower];
|
|
63
|
+
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
// Never re-attempt the exact provider:model that just failed.
|
|
66
|
+
const current = resolveTier(currentTier, opts.selector);
|
|
67
|
+
if (current) seen.add(`${current.provider}:${current.model}`);
|
|
68
|
+
|
|
69
|
+
const chain = [];
|
|
70
|
+
for (const tier of ordered) {
|
|
71
|
+
const resolved = resolveTier(tier, opts.selector);
|
|
72
|
+
if (!resolved) continue;
|
|
73
|
+
const key = `${resolved.provider}:${resolved.model}`;
|
|
74
|
+
if (seen.has(key)) continue;
|
|
75
|
+
seen.add(key);
|
|
76
|
+
if (!isAvailable(resolved.provider)) continue;
|
|
77
|
+
chain.push({
|
|
78
|
+
...resolved,
|
|
79
|
+
demotedFrom: currentTier,
|
|
80
|
+
direction: TIER_ORDER.indexOf(tier) > idx ? "up" : "down",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logger.debug(
|
|
85
|
+
{ currentTier, chain: chain.map((c) => `${c.tier}:${c.provider}`) },
|
|
86
|
+
"[TierFallback] Built fallback chain"
|
|
87
|
+
);
|
|
88
|
+
return chain;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { getFallbackChain, resolveTier, TIER_ORDER };
|
package/src/server.js
CHANGED
|
@@ -29,6 +29,7 @@ const { registerTaskTools } = require("./tools/tasks");
|
|
|
29
29
|
const { registerTestTools } = require("./tools/tests");
|
|
30
30
|
const { registerMcpTools } = require("./tools/mcp");
|
|
31
31
|
const { registerAgentTaskTool } = require("./tools/agent-task");
|
|
32
|
+
const { registerDecomposeTool } = require("./tools/decompose");
|
|
32
33
|
const { initConfigWatcher, getConfigWatcher } = require("./config/watcher");
|
|
33
34
|
const { initializeHeadroom, shutdownHeadroom, getHeadroomManager } = require("./headroom");
|
|
34
35
|
const { getWorkerPool, isWorkerPoolReady } = require("./workers/pool");
|
|
@@ -62,6 +63,7 @@ if (LAZY_TOOLS_ENABLED) {
|
|
|
62
63
|
registerTestTools();
|
|
63
64
|
registerMcpTools();
|
|
64
65
|
registerAgentTaskTool();
|
|
66
|
+
registerDecomposeTool();
|
|
65
67
|
logger.info({ mode: "eager" }, "All tools loaded at startup");
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const { registerTool } = require(".");
|
|
2
|
+
const { runDecomposedTask } = require("../agents/decomposition");
|
|
3
|
+
const logger = require("../logger");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DecomposeTask tool — breaks a complex task into focused subtasks with isolated
|
|
7
|
+
* context, runs them (parallel where independent), and synthesizes the result.
|
|
8
|
+
*
|
|
9
|
+
* Opt-in: requires TASK_DECOMPOSITION_ENABLED=true and AGENTS_ENABLED=true.
|
|
10
|
+
* Degrades gracefully — if the gate decides decomposition isn't worth it (or
|
|
11
|
+
* planning fails), it returns ok:true with decomposed:false and a reason so the
|
|
12
|
+
* caller can solve the task monolithically.
|
|
13
|
+
*/
|
|
14
|
+
function registerDecomposeTool() {
|
|
15
|
+
registerTool(
|
|
16
|
+
"DecomposeTask",
|
|
17
|
+
async ({ args = {} }, context = {}) => {
|
|
18
|
+
const task = args.task || args.prompt || args.description;
|
|
19
|
+
|
|
20
|
+
if (!task || typeof task !== "string") {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
status: 400,
|
|
24
|
+
content: JSON.stringify({ error: "task is required" }, null, 2),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.info(
|
|
29
|
+
{ task: task.slice(0, 100), sessionId: context.sessionId },
|
|
30
|
+
"DecomposeTask: evaluating task for decomposition"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const result = await runDecomposedTask(task, {
|
|
35
|
+
sessionId: context.sessionId,
|
|
36
|
+
cwd: context.cwd,
|
|
37
|
+
riskLevel: args.riskLevel || context.riskLevel,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (result.decomposed) {
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
status: 200,
|
|
44
|
+
content: result.result,
|
|
45
|
+
metadata: {
|
|
46
|
+
decomposed: true,
|
|
47
|
+
subtasks: result.plan?.subtasks?.length,
|
|
48
|
+
levels: result.stats?.levels,
|
|
49
|
+
strategy: result.plan?.strategy,
|
|
50
|
+
confidence: result.quality?.confidence,
|
|
51
|
+
recommendFallback: result.recommendFallback,
|
|
52
|
+
savedTokens: result.savings?.savedTokens,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Not decomposed — signal the caller to solve monolithically.
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
status: 200,
|
|
61
|
+
content: JSON.stringify(
|
|
62
|
+
{
|
|
63
|
+
decomposed: false,
|
|
64
|
+
reason: result.reason,
|
|
65
|
+
guidance: "Solve this task directly without decomposition.",
|
|
66
|
+
},
|
|
67
|
+
null,
|
|
68
|
+
2
|
|
69
|
+
),
|
|
70
|
+
metadata: { decomposed: false, reason: result.reason },
|
|
71
|
+
};
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logger.error({ error: error.message }, "DecomposeTask: error");
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 500,
|
|
77
|
+
content: JSON.stringify(
|
|
78
|
+
{ error: "Decomposition error", message: error.message },
|
|
79
|
+
null,
|
|
80
|
+
2
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{ category: "decompose" }
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
logger.info("DecomposeTask tool registered");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { registerDecomposeTool };
|
package/src/tools/lazy-loader.js
CHANGED
|
@@ -77,6 +77,11 @@ const TOOL_CATEGORIES = {
|
|
|
77
77
|
loader: () => require('./agent-task').registerAgentTaskTool,
|
|
78
78
|
priority: 2,
|
|
79
79
|
},
|
|
80
|
+
decompose: {
|
|
81
|
+
keywords: ['decompose', 'subtask', 'break down', 'break into', 'split task', 'plan and execute'],
|
|
82
|
+
loader: () => require('./decompose').registerDecomposeTool,
|
|
83
|
+
priority: 2,
|
|
84
|
+
},
|
|
80
85
|
'code-mode': {
|
|
81
86
|
keywords: ['mcp', 'execute', 'server', 'tool', 'code mode'],
|
|
82
87
|
loader: () => require('./code-mode').registerCodeModeTools,
|
|
@@ -296,6 +301,9 @@ function loadCategoryForTool(toolName) {
|
|
|
296
301
|
// TinyFish (web agent)
|
|
297
302
|
'web_agent': 'tinyfish',
|
|
298
303
|
'agent_task': 'agentTask',
|
|
304
|
+
|
|
305
|
+
// Task decomposition
|
|
306
|
+
'decomposetask': 'decompose',
|
|
299
307
|
};
|
|
300
308
|
|
|
301
309
|
// Direct mapping
|