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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.5.0",
3
+ "version": "9.7.0",
4
4
  "description": "Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "dev": "nodemon index.js",
17
17
  "lint": "eslint src index.js",
18
18
  "test": "npm run test:unit && npm run test:performance",
19
- "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js",
19
+ "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/code-mode.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/task-decomposition.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js",
20
20
  "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
21
21
  "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
22
22
  "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -79,6 +79,7 @@
79
79
  "express": "^5.1.0",
80
80
  "express-rate-limit": "^8.2.1",
81
81
  "fast-glob": "^3.3.2",
82
+ "graphify": "^1.0.0",
82
83
  "hnswlib-node": "^3.0.0",
83
84
  "js-tiktoken": "^1.0.20",
84
85
  "js-yaml": "^4.1.1",
@@ -89,7 +90,7 @@
89
90
  "undici": "^6.22.0"
90
91
  },
91
92
  "optionalDependencies": {
92
- "better-sqlite3": "^12.6.2",
93
+ "better-sqlite3": "^12.11.1",
93
94
  "dockerode": "^4.0.2",
94
95
  "tree-sitter": "^0.21.1",
95
96
  "tree-sitter-javascript": "^0.21.0",
@@ -51,7 +51,7 @@ async function _readTelemetry(days) {
51
51
  return db
52
52
  .prepare(
53
53
  `SELECT request_text AS query, provider, model, quality_score AS quality,
54
- cost, total_latency_ms AS latency, tier
54
+ cost_usd AS cost, latency_ms AS latency, tier
55
55
  FROM routing_telemetry
56
56
  WHERE timestamp >= ?
57
57
  AND quality_score IS NOT NULL
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert routellm/gpt4_dataset (train.jsonl) → Lynkr kNN bootstrap JSONL.
4
+
5
+ Stream-downloads from HuggingFace and maps mixtral_score (1-5) to your
6
+ TIER_* model config so the kNN index learns to recommend YOUR real models,
7
+ not RouteLLM's pool (mixtral / gpt-4).
8
+
9
+ Output format matches scripts/build-knn-index.js bootstrap input:
10
+ {"query": "...", "provider": "ollama", "model": "minimax-m2.5:cloud",
11
+ "quality": 95, "cost": 0.001, "latency": 200, "tier": "SIMPLE"}
12
+
13
+ Usage:
14
+ .venv/bin/python scripts/convert-routellm.py [--limit 30000] -o routellm.jsonl
15
+ """
16
+ import argparse
17
+ import json
18
+ import sys
19
+ import urllib.request
20
+ import ssl
21
+
22
+ # Map mixtral_score → user's tier config.
23
+ # Spread is intentionally aggressive: only score=5 (Mixtral truly nailed it)
24
+ # stays SIMPLE; score=4 escalates to MEDIUM so the kNN index has enough MEDIUM
25
+ # examples to actually recommend non-SIMPLE tiers (vs the previous mapping
26
+ # where 86% of records were SIMPLE and kNN never disagreed with the heuristic).
27
+ SCORE_TO_ROUTE = {
28
+ 5: {"tier": "SIMPLE", "provider": "ollama", "model": "minimax-m2.5:cloud", "quality": 95, "cost": 0.001, "latency": 200},
29
+ 4: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 80, "cost": 0.003, "latency": 1500},
30
+ 3: {"tier": "MEDIUM", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 70, "cost": 0.003, "latency": 1500},
31
+ 2: {"tier": "COMPLEX", "provider": "azure-anthropic", "model": "claude-sonnet-4-6", "quality": 55, "cost": 0.003, "latency": 1500},
32
+ 1: {"tier": "REASONING", "provider": "azure-anthropic", "model": "claude-opus-4-8", "quality": 40, "cost": 0.015, "latency": 3500},
33
+ }
34
+
35
+ URL = "https://huggingface.co/datasets/routellm/gpt4_dataset/resolve/main/train.jsonl"
36
+
37
+
38
+ def main():
39
+ p = argparse.ArgumentParser()
40
+ p.add_argument("--output", "-o", default="data/routellm-bootstrap.jsonl")
41
+ p.add_argument("--limit", type=int, default=None,
42
+ help="Only convert first N rows")
43
+ args = p.parse_args()
44
+
45
+ print(f"Streaming {URL} …", file=sys.stderr)
46
+ # Some macOS Python builds have SSL issues with huggingface.co — fall back
47
+ # to unverified context if needed. The data is public, no auth.
48
+ ctx = ssl.create_default_context()
49
+ try:
50
+ with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
51
+ _stream(resp, args.output, args.limit)
52
+ except (ssl.SSLError, OSError) as e:
53
+ print(f"SSL strict failed ({e}); retrying with unverified context", file=sys.stderr)
54
+ ctx = ssl._create_unverified_context()
55
+ with urllib.request.urlopen(URL, context=ctx, timeout=60) as resp:
56
+ _stream(resp, args.output, args.limit)
57
+
58
+
59
+ def _stream(resp, out_path, limit):
60
+ written = 0
61
+ by_score = {s: 0 for s in SCORE_TO_ROUTE}
62
+ skipped = 0
63
+ with open(out_path, "w") as out:
64
+ for line in resp:
65
+ if limit and written >= limit:
66
+ break
67
+ try:
68
+ row = json.loads(line)
69
+ except Exception:
70
+ skipped += 1
71
+ continue
72
+ score = row.get("mixtral_score")
73
+ if score not in SCORE_TO_ROUTE:
74
+ skipped += 1
75
+ continue
76
+ route = SCORE_TO_ROUTE[score]
77
+ rec = {
78
+ "query": row.get("prompt", ""),
79
+ "provider": route["provider"],
80
+ "model": route["model"],
81
+ "quality": route["quality"],
82
+ "cost": route["cost"],
83
+ "latency": route["latency"],
84
+ "tier": route["tier"],
85
+ }
86
+ if not rec["query"]:
87
+ skipped += 1
88
+ continue
89
+ out.write(json.dumps(rec) + "\n")
90
+ written += 1
91
+ by_score[score] += 1
92
+ if written % 5000 == 0:
93
+ print(f" {written} written…", file=sys.stderr)
94
+
95
+ print(f"\nWrote {written} records to {out_path} ({skipped} skipped)", file=sys.stderr)
96
+ print(f"By mixtral_score: {by_score}", file=sys.stderr)
97
+ print(f"\nTier distribution:", file=sys.stderr)
98
+ for score, route in SCORE_TO_ROUTE.items():
99
+ n = by_score[score]
100
+ if n:
101
+ print(f" {route['tier']:>10} {route['provider']}:{route['model']:30s} n={n}", file=sys.stderr)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Subtask dispatcher (Phase 3).
3
+ *
4
+ * Executes a validated plan respecting its dependency DAG:
5
+ * - subtasks are grouped into topological "levels" (Kahn's algorithm)
6
+ * - subtasks in the same level have no dependency on each other → run in
7
+ * parallel via the existing ParallelCoordinator (spawnParallel)
8
+ * - a subtask receives ONLY its own prompt plus the compressed results of the
9
+ * subtasks it depends on (context isolation — the token win)
10
+ *
11
+ * The spawn functions are injectable for testing.
12
+ */
13
+
14
+ const logger = require("../../logger");
15
+
16
+ // Cap how much of a dependency's result we forward, to preserve the
17
+ // context-isolation savings (subagents already return summaries; this bounds
18
+ // pathological cases).
19
+ const MAX_CONTEXT_CHARS = 2000;
20
+
21
+ /**
22
+ * Group subtasks into dependency levels. Returns array of arrays of ids.
23
+ * Throws if the graph is unresolvable (should not happen — planner validated).
24
+ */
25
+ function topologicalLevels(subtasks) {
26
+ const byId = new Map(subtasks.map((s) => [s.id, s]));
27
+ const indegree = new Map(subtasks.map((s) => [s.id, 0]));
28
+ const dependents = new Map(subtasks.map((s) => [s.id, []]));
29
+
30
+ for (const s of subtasks) {
31
+ for (const dep of s.dependsOn) {
32
+ if (!byId.has(dep)) continue;
33
+ indegree.set(s.id, indegree.get(s.id) + 1);
34
+ dependents.get(dep).push(s.id);
35
+ }
36
+ }
37
+
38
+ const levels = [];
39
+ let frontier = subtasks.filter((s) => indegree.get(s.id) === 0).map((s) => s.id);
40
+ const resolved = new Set();
41
+
42
+ while (frontier.length > 0) {
43
+ levels.push(frontier);
44
+ const next = [];
45
+ for (const id of frontier) {
46
+ resolved.add(id);
47
+ for (const child of dependents.get(id)) {
48
+ indegree.set(child, indegree.get(child) - 1);
49
+ if (indegree.get(child) === 0) next.push(child);
50
+ }
51
+ }
52
+ frontier = next;
53
+ }
54
+
55
+ if (resolved.size !== subtasks.length) {
56
+ throw new Error("Unresolvable subtask graph (cycle or dangling dependency)");
57
+ }
58
+ return levels;
59
+ }
60
+
61
+ function compressResult(text) {
62
+ if (typeof text !== "string") return String(text ?? "");
63
+ if (text.length <= MAX_CONTEXT_CHARS) return text;
64
+ return text.slice(0, MAX_CONTEXT_CHARS) + "\n…[truncated]";
65
+ }
66
+
67
+ function buildContextForSubtask(subtask, resultsById) {
68
+ if (!subtask.dependsOn || subtask.dependsOn.length === 0) return null;
69
+ const parts = [];
70
+ for (const dep of subtask.dependsOn) {
71
+ const r = resultsById.get(dep);
72
+ if (r && r.success && r.result) {
73
+ parts.push(`Result of subtask ${dep}:\n${compressResult(r.result)}`);
74
+ } else if (r) {
75
+ parts.push(`Subtask ${dep} did not complete successfully.`);
76
+ }
77
+ }
78
+ return parts.length > 0 ? parts.join("\n\n") : null;
79
+ }
80
+
81
+ /**
82
+ * Dispatch a validated plan.
83
+ * @param {Object} plan - { subtasks: [...] }
84
+ * @param {Object} [options]
85
+ * @param {string} [options.sessionId]
86
+ * @param {string} [options.cwd]
87
+ * @param {Function} [options.spawnParallel] - (agentTypes[], prompts[], opts) => results[]
88
+ * @returns {Promise<{results: Array, levels: Array, stats: Object}>}
89
+ */
90
+ async function dispatchPlan(plan, options = {}) {
91
+ const spawnParallel = options.spawnParallel || require("../index").spawnParallel;
92
+ const subtasks = plan.subtasks;
93
+ const byId = new Map(subtasks.map((s) => [s.id, s]));
94
+ const levels = topologicalLevels(subtasks);
95
+ const resultsById = new Map();
96
+
97
+ let totalInputTokens = 0;
98
+ let totalOutputTokens = 0;
99
+ let totalSubagents = 0;
100
+
101
+ for (let li = 0; li < levels.length; li++) {
102
+ const levelIds = levels[li];
103
+ const levelSubtasks = levelIds.map((id) => byId.get(id));
104
+
105
+ const agentTypes = levelSubtasks.map((s) => s.agentType);
106
+ const prompts = levelSubtasks.map((s) => s.prompt);
107
+ const perTaskContext = levelSubtasks.map((s) => buildContextForSubtask(s, resultsById));
108
+
109
+ logger.info(
110
+ { level: li, count: levelIds.length, ids: levelIds },
111
+ "[Decomposition] Dispatching subtask level"
112
+ );
113
+
114
+ // spawnParallel shares one options object; pass per-task context by spawning
115
+ // the level as individual parallel calls when contexts differ.
116
+ const levelResults = await runLevel(
117
+ spawnParallel,
118
+ agentTypes,
119
+ prompts,
120
+ perTaskContext,
121
+ options
122
+ );
123
+
124
+ levelResults.forEach((res, idx) => {
125
+ const st = levelSubtasks[idx];
126
+ totalSubagents += 1;
127
+ totalInputTokens += res?.stats?.inputTokens || 0;
128
+ totalOutputTokens += res?.stats?.outputTokens || 0;
129
+ resultsById.set(st.id, {
130
+ id: st.id,
131
+ agentType: st.agentType,
132
+ success: !!res?.success,
133
+ result: res?.success ? res.result : null,
134
+ error: res?.success ? null : res?.error || "unknown error",
135
+ stats: res?.stats || {},
136
+ });
137
+ });
138
+ }
139
+
140
+ const results = subtasks.map((s) => resultsById.get(s.id));
141
+ return {
142
+ results,
143
+ levels,
144
+ stats: {
145
+ subagents: totalSubagents,
146
+ inputTokens: totalInputTokens,
147
+ outputTokens: totalOutputTokens,
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Run one level. When subtasks in the level have differing injected contexts we
154
+ * spawn them as separate parallel calls (each with its own mainContext), then
155
+ * await all. When none need context, a single spawnParallel batch is used.
156
+ */
157
+ async function runLevel(spawnParallel, agentTypes, prompts, perTaskContext, options) {
158
+ const anyContext = perTaskContext.some((c) => c);
159
+
160
+ if (!anyContext) {
161
+ return spawnParallel(agentTypes, prompts, {
162
+ sessionId: options.sessionId,
163
+ cwd: options.cwd,
164
+ });
165
+ }
166
+
167
+ // Mixed/with-context: one spawnParallel call per subtask so each gets its own
168
+ // mainContext, executed concurrently.
169
+ const calls = agentTypes.map((type, i) =>
170
+ spawnParallel([type], [prompts[i]], {
171
+ sessionId: options.sessionId,
172
+ cwd: options.cwd,
173
+ mainContext: perTaskContext[i] ? { relevant_context: perTaskContext[i] } : undefined,
174
+ }).then((arr) => arr[0])
175
+ );
176
+ return Promise.all(calls);
177
+ }
178
+
179
+ module.exports = {
180
+ dispatchPlan,
181
+ topologicalLevels,
182
+ buildContextForSubtask,
183
+ compressResult,
184
+ MAX_CONTEXT_CHARS,
185
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Decomposition gate (Phase 1).
3
+ *
4
+ * Decides whether breaking a task into isolated-context subtasks is actually
5
+ * worth it. This is the make-or-break of the feature: naive "decompose
6
+ * everything" loses money, because every subagent carries fixed overhead
7
+ * (planning + per-agent handoff/summarisation). Decomposition only pays off
8
+ * when the task is (a) genuinely complex, (b) large enough to amortise that
9
+ * overhead, and (c) divisible into reasonably independent units.
10
+ *
11
+ * Pure and synchronous so it can be unit-tested without a model. The caller
12
+ * supplies a pre-computed complexity `analysis` (from routing/complexity-analyzer)
13
+ * and the raw payload.
14
+ */
15
+
16
+ const DEFAULTS = {
17
+ minComplexity: 60, // 0-100; only decompose genuinely complex work
18
+ minTokens: 3000, // estimated monolithic tokens; below this the overhead wins
19
+ minIndependentUnits: 2, // need at least 2 separable pieces to bother
20
+ maxSubtasks: 6,
21
+ };
22
+
23
+ const ENUMERATION_RE = /^\s*(?:[-*+]|\d+[.)]|step\s+\d+\b)/gim;
24
+ const CONJUNCTION_RE = /\b(?:and then|then|also|additionally|as well as|after that|finally|next,)\b/gi;
25
+ const IMPERATIVE_RE = /\b(?:add|create|build|implement|write|refactor|update|fix|remove|delete|migrate|test|document|configure|set up|wire|integrate|generate)\b/gi;
26
+ const FILE_PATH_RE = /\b[\w./-]+\.(?:js|ts|tsx|jsx|py|go|rs|java|rb|c|cpp|h|json|yaml|yml|md|sql|sh|css|html)\b/gi;
27
+
28
+ function _uniqueMatches(text, re) {
29
+ const set = new Set();
30
+ const matches = text.match(re) || [];
31
+ for (const m of matches) set.add(m.toLowerCase().trim());
32
+ return set;
33
+ }
34
+
35
+ /**
36
+ * Heuristically estimate how many independent units a task contains.
37
+ * Conservative: takes the strongest of several weak signals rather than summing
38
+ * them, so a single rambling sentence doesn't look like five subtasks.
39
+ * @param {string} text
40
+ * @returns {number}
41
+ */
42
+ function estimateIndependentUnits(text) {
43
+ if (!text || typeof text !== "string") return 1;
44
+
45
+ const enumerated = (text.match(ENUMERATION_RE) || []).length;
46
+ const conjunctions = (text.match(CONJUNCTION_RE) || []).length;
47
+ const imperatives = _uniqueMatches(text, IMPERATIVE_RE).size;
48
+ const files = _uniqueMatches(text, FILE_PATH_RE).size;
49
+
50
+ // Each signal is an independent lower-bound estimate of separable units.
51
+ const signals = [
52
+ enumerated, // explicit list items
53
+ conjunctions + 1, // "do A and then B" → 2 units
54
+ imperatives, // distinct action verbs
55
+ files, // distinct files usually map to distinct work
56
+ ];
57
+
58
+ const estimate = Math.max(...signals, 1);
59
+ return estimate;
60
+ }
61
+
62
+ /**
63
+ * Decide whether to decompose.
64
+ * @param {Object} analysis - result of analyzeComplexity(payload)
65
+ * @param {Object} payload - the request payload
66
+ * @param {Object} [options]
67
+ * @param {Object} [options.config] - threshold overrides (see DEFAULTS)
68
+ * @param {string} [options.riskLevel] - 'low'|'medium'|'high'; 'high' disables decomposition
69
+ * @param {string} [options.taskText] - explicit task text (else derived from analysis/payload)
70
+ * @returns {{ decompose: boolean, reason: string, signals: Object }}
71
+ */
72
+ function shouldDecompose(analysis, payload = {}, options = {}) {
73
+ const cfg = { ...DEFAULTS, ...(options.config || {}) };
74
+
75
+ const score = Number(analysis?.score ?? 0);
76
+ const estimatedTokens = Number(
77
+ analysis?.breakdown?.tokens?.estimated ?? options.estimatedTokens ?? 0
78
+ );
79
+
80
+ const taskText =
81
+ options.taskText ||
82
+ analysis?.content ||
83
+ _firstUserText(payload) ||
84
+ "";
85
+
86
+ const independentUnits = estimateIndependentUnits(taskText);
87
+
88
+ const signals = {
89
+ score,
90
+ estimatedTokens,
91
+ independentUnits,
92
+ riskLevel: options.riskLevel || "low",
93
+ thresholds: cfg,
94
+ };
95
+
96
+ // Never decompose high-risk work — keep it in one capable context where the
97
+ // exempt-from-laziness concerns (validation/security) stay coherent.
98
+ if (options.riskLevel === "high") {
99
+ return { decompose: false, reason: "high_risk_skip", signals };
100
+ }
101
+
102
+ if (score < cfg.minComplexity) {
103
+ return { decompose: false, reason: "below_complexity_threshold", signals };
104
+ }
105
+
106
+ if (estimatedTokens < cfg.minTokens) {
107
+ return { decompose: false, reason: "too_small_to_amortise_overhead", signals };
108
+ }
109
+
110
+ if (independentUnits < cfg.minIndependentUnits) {
111
+ return { decompose: false, reason: "not_divisible", signals };
112
+ }
113
+
114
+ return { decompose: true, reason: "decompose_worthwhile", signals };
115
+ }
116
+
117
+ function _firstUserText(payload) {
118
+ const messages = payload?.messages;
119
+ if (!Array.isArray(messages)) return "";
120
+ const user = [...messages].reverse().find((m) => m.role === "user");
121
+ if (!user) return "";
122
+ if (typeof user.content === "string") return user.content;
123
+ if (Array.isArray(user.content)) {
124
+ return user.content
125
+ .filter((b) => b?.type === "text" || typeof b?.text === "string")
126
+ .map((b) => b.text || "")
127
+ .join("\n");
128
+ }
129
+ return "";
130
+ }
131
+
132
+ module.exports = {
133
+ shouldDecompose,
134
+ estimateIndependentUnits,
135
+ DEFAULTS,
136
+ };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Task decomposition — orchestration entry point.
3
+ *
4
+ * Ties the phases together:
5
+ * 1. gate — decide if decomposing is worth it (cost-aware)
6
+ * 2. planner — produce a validated subtask DAG (one model call)
7
+ * 3. dispatcher — run subtasks (parallel within dependency levels, isolated context)
8
+ * 4. synthesizer — combine results into the final answer (one model call)
9
+ * 5. quality — confidence-score the synthesis; flag low-confidence output
10
+ * 6. telemetry — record decision + estimated net token savings; shadow mode
11
+ *
12
+ * Opt-in via TASK_DECOMPOSITION_ENABLED=true. Requires AGENTS_ENABLED=true
13
+ * (it builds on the subagent machinery). Any failure degrades gracefully to a
14
+ * non-decomposed result so the caller can solve monolithically.
15
+ */
16
+
17
+ const config = require("../../config");
18
+ const logger = require("../../logger");
19
+ const { shouldDecompose } = require("./gate");
20
+ const { generatePlan } = require("./planner");
21
+ const { dispatchPlan } = require("./dispatcher");
22
+ const { synthesize } = require("./synthesizer");
23
+ const telemetry = require("./telemetry");
24
+ const { analyzeComplexity } = require("../../routing/complexity-analyzer");
25
+ const confidenceScorer = require("../../routing/confidence-scorer");
26
+
27
+ const CODE_HINT_RE = /\b(code|function|implement|refactor|bug|class|api|module|test)\b/i;
28
+
29
+ function getConfig() {
30
+ return (
31
+ config.taskDecomposition || {
32
+ enabled: false,
33
+ shadow: false,
34
+ planModel: "sonnet",
35
+ synthModel: "sonnet",
36
+ minConfidence: 0.5,
37
+ gate: {},
38
+ }
39
+ );
40
+ }
41
+
42
+ /**
43
+ * @param {string} task - the task text to (maybe) decompose
44
+ * @param {Object} [options]
45
+ * @param {string} [options.sessionId]
46
+ * @param {string} [options.cwd]
47
+ * @param {string} [options.riskLevel] - 'high' disables decomposition
48
+ * @param {Object} [options._inject] - test seams { generatePlan, dispatchPlan, synthesize, analyze }
49
+ * @returns {Promise<Object>} result object (see below)
50
+ */
51
+ async function runDecomposedTask(task, options = {}) {
52
+ const cfg = getConfig();
53
+ const inject = options._inject || {};
54
+
55
+ if (!cfg.enabled) {
56
+ return { decomposed: false, reason: "disabled" };
57
+ }
58
+ if (!config.agents?.enabled) {
59
+ return { decomposed: false, reason: "agents_disabled" };
60
+ }
61
+ if (!task || typeof task !== "string") {
62
+ return { decomposed: false, reason: "empty_task" };
63
+ }
64
+
65
+ const payload = { messages: [{ role: "user", content: task }] };
66
+
67
+ let analysis;
68
+ try {
69
+ analysis = await (inject.analyze || analyzeComplexity)(payload);
70
+ } catch (err) {
71
+ logger.warn({ err: err.message }, "[Decomposition] Complexity analysis failed");
72
+ return { decomposed: false, reason: "analysis_failed" };
73
+ }
74
+
75
+ const monolithicTokens =
76
+ analysis?.breakdown?.tokens?.estimated || telemetry.estimateTokens(task);
77
+
78
+ const gate = shouldDecompose(analysis, payload, {
79
+ config: cfg.gate,
80
+ riskLevel: options.riskLevel,
81
+ taskText: task,
82
+ });
83
+
84
+ // Shadow mode: record what we WOULD do, but never actually decompose.
85
+ if (cfg.shadow) {
86
+ telemetry.record({
87
+ mode: "shadow",
88
+ sessionId: options.sessionId,
89
+ gate,
90
+ monolithicTokens,
91
+ });
92
+ return { decomposed: false, reason: "shadow_mode", gate };
93
+ }
94
+
95
+ if (!gate.decompose) {
96
+ telemetry.record({ mode: "live", decision: "skip", gate, monolithicTokens });
97
+ return { decomposed: false, reason: gate.reason, gate };
98
+ }
99
+
100
+ // Phase 2: plan
101
+ const plan = await (inject.generatePlan || generatePlan)({
102
+ task,
103
+ model: cfg.planModel,
104
+ maxSubtasks: cfg.gate?.maxSubtasks || 6,
105
+ });
106
+ if (!plan) {
107
+ telemetry.record({ mode: "live", decision: "plan_failed", gate, monolithicTokens });
108
+ return { decomposed: false, reason: "plan_failed", gate };
109
+ }
110
+
111
+ // Phase 3: dispatch
112
+ const dispatch = await (inject.dispatchPlan || dispatchPlan)(plan, {
113
+ sessionId: options.sessionId,
114
+ cwd: options.cwd,
115
+ });
116
+
117
+ // Phase 4: synthesize
118
+ const synth = await (inject.synthesize || synthesize)({
119
+ task,
120
+ subtaskResults: dispatch.results,
121
+ model: cfg.synthModel,
122
+ });
123
+
124
+ // Phase 5: quality gate
125
+ const taskType = CODE_HINT_RE.test(task) ? "code" : "reasoning";
126
+ let confidence = 1;
127
+ try {
128
+ confidence = await confidenceScorer.score(
129
+ { content: [{ type: "text", text: synth.text }] },
130
+ { taskType }
131
+ );
132
+ } catch (err) {
133
+ logger.debug({ err: err.message }, "[Decomposition] Confidence scoring failed");
134
+ }
135
+ const belowThreshold = confidence < (cfg.minConfidence ?? 0.5);
136
+
137
+ const savings = telemetry.estimateSavings({
138
+ monolithicTokens,
139
+ planUsage: plan.usage,
140
+ dispatchStats: dispatch.stats,
141
+ synthUsage: synth.usage,
142
+ });
143
+
144
+ telemetry.record({
145
+ mode: "live",
146
+ decision: "decomposed",
147
+ sessionId: options.sessionId,
148
+ gate,
149
+ subtasks: plan.subtasks.length,
150
+ levels: dispatch.levels.length,
151
+ strategy: plan.strategy,
152
+ confidence,
153
+ belowThreshold,
154
+ synthesisFallback: synth.fallback,
155
+ savings,
156
+ });
157
+
158
+ logger.info(
159
+ {
160
+ subtasks: plan.subtasks.length,
161
+ levels: dispatch.levels.length,
162
+ confidence: confidence.toFixed(2),
163
+ savedTokens: savings.savedTokens,
164
+ },
165
+ "[Decomposition] Task decomposed"
166
+ );
167
+
168
+ return {
169
+ decomposed: true,
170
+ result: synth.text,
171
+ reason: "decomposed",
172
+ plan,
173
+ subtaskResults: dispatch.results,
174
+ quality: { confidence, belowThreshold, taskType },
175
+ // When confidence is low, the caller should prefer a monolithic re-solve.
176
+ recommendFallback: belowThreshold,
177
+ stats: { ...dispatch.stats, levels: dispatch.levels.length },
178
+ savings,
179
+ gate,
180
+ };
181
+ }
182
+
183
+ module.exports = { runDecomposedTask, getConfig };