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.
@@ -486,7 +486,10 @@ async function _determineProviderSmartInner(payload, options = {}) {
486
486
  : null;
487
487
  if (queryText) {
488
488
  knnResult = await getKnnRouter().query(queryText);
489
- if (knnResult && knnResult.confidence > 0.7 && knnResult.model && knnResult.model !== selectedModel) {
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 > 0.4 && knnResult.confidence <= 0.7) {
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
- allCandidates.push({ provider: knnResult.provider, model: knnResult.model });
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
- const MIN_INDEX_SIZE = 1000;
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
- this.index.readIndexSync(INDEX_FILE, MAX_ELEMENTS);
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 };
@@ -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