lynkr 9.4.6 → 9.6.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.
Files changed (35) hide show
  1. package/README.md +49 -15
  2. package/install.sh +21 -5
  3. package/package.json +4 -2
  4. package/public/dashboard.html +13 -1
  5. package/scripts/check-native.js +97 -0
  6. package/src/agents/decomposition/dispatcher.js +185 -0
  7. package/src/agents/decomposition/gate.js +136 -0
  8. package/src/agents/decomposition/index.js +183 -0
  9. package/src/agents/decomposition/model-call.js +75 -0
  10. package/src/agents/decomposition/planner.js +223 -0
  11. package/src/agents/decomposition/synthesizer.js +89 -0
  12. package/src/agents/decomposition/telemetry.js +55 -0
  13. package/src/clients/databricks.js +215 -4
  14. package/src/clients/openrouter-utils.js +19 -27
  15. package/src/clients/prompt-cache-injection.js +49 -0
  16. package/src/clients/responses-format.js +7 -0
  17. package/src/clients/tool-call-repair.js +130 -0
  18. package/src/config/index.js +32 -0
  19. package/src/context/caveman.js +94 -0
  20. package/src/context/output-format-guard.js +99 -0
  21. package/src/context/tool-dedup.js +95 -0
  22. package/src/context/tool-result-compressor.js +106 -0
  23. package/src/dashboard/api.js +69 -18
  24. package/src/orchestrator/bypass.js +135 -0
  25. package/src/orchestrator/index.js +33 -2
  26. package/src/routing/index.js +47 -0
  27. package/src/routing/model-registry.js +89 -26
  28. package/src/routing/risk-analyzer.js +7 -2
  29. package/src/routing/session-affinity.js +96 -0
  30. package/src/routing/telemetry.js +16 -3
  31. package/src/routing/tier-fallback.js +91 -0
  32. package/src/server.js +2 -0
  33. package/src/tools/decompose.js +91 -0
  34. package/src/tools/lazy-loader.js +8 -0
  35. package/.impeccable/live/config.json +0 -8
@@ -18,6 +18,7 @@ const { createAuditLogger } = require("../logger/audit-logger");
18
18
  const { getResolvedIp, runWithDnsContext } = require("../clients/dns-logger");
19
19
  const { getShuttingDown } = require("../api/health");
20
20
  const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
21
+ const { detectBypass, buildBypassResponse } = require("./bypass");
21
22
  const crypto = require("crypto");
22
23
  const { asyncClone, asyncTransform, getPoolStats } = require("../workers/helpers");
23
24
  const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
@@ -1362,8 +1363,12 @@ function sanitizePayload(payload) {
1362
1363
  delete clean.tool_choice;
1363
1364
  }
1364
1365
 
1365
- // Smart tool selection (universal, applies to all providers)
1366
- if (config.smartToolSelection?.enabled && Array.isArray(clean.tools) && clean.tools.length > 0) {
1366
+ // Smart tool selection (server mode only). In client/passthrough mode the
1367
+ // client (e.g. Claude Code) owns tool execution, so stripping its tools would
1368
+ // make the model emit calls for tools we removed — they then get dropped as
1369
+ // "hallucinated" and the session makes no progress. Pass tools through intact.
1370
+ const inClientMode = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
1371
+ if (!inClientMode && config.smartToolSelection?.enabled && Array.isArray(clean.tools) && clean.tools.length > 0) {
1367
1372
  const classification = classifyRequestType(clean);
1368
1373
  const selectedTools = selectToolsSmartly(clean.tools, classification, {
1369
1374
  provider: providerType,
@@ -1977,6 +1982,12 @@ IMPORTANT TOOL USAGE RULES:
1977
1982
  cleanPayload._tenantPolicy = options.tenantPolicy;
1978
1983
  }
1979
1984
 
1985
+ // Thread session id for provider affinity — keeps a tool-bearing
1986
+ // conversation on one provider so tool_call_id linkage doesn't break.
1987
+ if (session?.id) {
1988
+ cleanPayload._sessionId = session.id;
1989
+ }
1990
+
1980
1991
  // RTK-inspired tool result compression: compress large tool_results
1981
1992
  // before they reach the model (saves 60-90% on test/git/lint output)
1982
1993
  if (config.toolResultCompression?.enabled !== false) {
@@ -1985,6 +1996,18 @@ IMPORTANT TOOL USAGE RULES:
1985
1996
  compressToolResults(cleanPayload.messages, { tier });
1986
1997
  }
1987
1998
 
1999
+ // MCP-aware tool dedup: drop built-in tools superseded by present MCP tools
2000
+ // (e.g. WebSearch/WebFetch when Exa/Tavily MCP is available). Always on.
2001
+ const { applyToolDedup } = require("../context/tool-dedup");
2002
+ applyToolDedup(cleanPayload);
2003
+
2004
+ // Caveman terse-output injection (opt-in): nudge the model toward shorter
2005
+ // responses to reduce output tokens.
2006
+ if (config.caveman?.enabled === true) {
2007
+ const { injectCaveman } = require("../context/caveman");
2008
+ cleanPayload.system = injectCaveman(cleanPayload.system);
2009
+ }
2010
+
1988
2011
  if (agentTimer) agentTimer.mark("preInvokeModel");
1989
2012
  let databricksResponse;
1990
2013
  try {
@@ -3735,6 +3758,14 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3735
3758
  };
3736
3759
  }
3737
3760
 
3761
+ // === REQUEST BYPASS ===
3762
+ // Claude CLI housekeeping (Warmup pings, topic/title extraction) doesn't
3763
+ // need a model call — return a canned response and skip the provider.
3764
+ const bypass = detectBypass({ payload, headers });
3765
+ if (bypass) {
3766
+ return buildBypassResponse(bypass, requestedModel);
3767
+ }
3768
+
3738
3769
  // === PREFLIGHT CHECK ===
3739
3770
  // If the request supplied preflight_commands and they all pass in
3740
3771
  // the workspace, the work is already done — short-circuit with a
@@ -138,7 +138,46 @@ function getBestLocalProvider() {
138
138
  * @param {Object} options - Routing options
139
139
  * @returns {Object} Routing decision with provider and metadata
140
140
  */
141
+ const sessionAffinity = require('./session-affinity');
142
+
143
+ /**
144
+ * Provider routing with session affinity.
145
+ *
146
+ * When a conversation already carries tool history, reuse the provider the
147
+ * session first routed to so tool-call IDs don't break across providers.
148
+ * Fresh turns route normally and refresh the session's pinned provider.
149
+ */
141
150
  async function determineProviderSmart(payload, options = {}) {
151
+ const sessionId = payload?._sessionId || null;
152
+
153
+ // Enforce affinity only for in-flight tool exchanges — the turns that 400
154
+ // if the provider changes. Fresh turns keep full per-turn tier routing.
155
+ if (sessionId && !options.forceProvider && sessionAffinity.payloadHasToolHistory(payload)) {
156
+ const pinned = sessionAffinity.getPinned(sessionId);
157
+ if (pinned) {
158
+ logger.debug({ sessionId, provider: pinned.provider, tier: pinned.tier },
159
+ '[Routing] Session affinity — reusing provider for tool-bearing turn');
160
+ return {
161
+ provider: pinned.provider,
162
+ model: pinned.model,
163
+ tier: pinned.tier,
164
+ method: 'session_affinity',
165
+ reason: 'tool_history_provider_pin',
166
+ };
167
+ }
168
+ }
169
+
170
+ const decision = await _determineProviderSmartInner(payload, options);
171
+
172
+ // Remember the chosen provider so later tool-bearing turns stay consistent.
173
+ if (sessionId && decision?.provider && !options.forceProvider) {
174
+ sessionAffinity.setPinned(sessionId, decision);
175
+ }
176
+
177
+ return decision;
178
+ }
179
+
180
+ async function _determineProviderSmartInner(payload, options = {}) {
142
181
  const primaryProvider = config.modelProvider?.type ?? 'databricks';
143
182
 
144
183
  // Risk analysis runs orthogonally to complexity. We compute it once
@@ -658,6 +697,14 @@ function getRoutingHeaders(decision) {
658
697
  headers['X-Lynkr-Cost-Optimized'] = 'true';
659
698
  }
660
699
 
700
+ // Tier-aware fallback surfacing (never silent).
701
+ if (decision.fallback) {
702
+ headers['X-Lynkr-Fallback'] = 'true';
703
+ if (decision.fromTier) headers['X-Lynkr-Fallback-From-Tier'] = decision.fromTier;
704
+ if (decision.servedTier) headers['X-Lynkr-Served-Tier'] = decision.servedTier;
705
+ if (decision.fallbackDirection) headers['X-Lynkr-Fallback-Direction'] = decision.fallbackDirection;
706
+ }
707
+
661
708
  if (decision.risk?.level) {
662
709
  headers['X-Lynkr-Risk'] = decision.risk.level;
663
710
  const hits = Array.from(new Set([
@@ -54,9 +54,41 @@ const DATABRICKS_FALLBACK = {
54
54
  'databricks-bge-large-en': { input: 0.02, output: 0, context: 512 },
55
55
  };
56
56
 
57
- // Default cost for unknown models
57
+ // Default cost for unknown models. Returned with `unknown: true` so callers can
58
+ // distinguish a real price from a fabricated guess.
58
59
  const DEFAULT_COST = { input: 1.0, output: 3.0, context: 128000 };
59
60
 
61
+ // Curated name aliases (exact, one-directional). Maps a name a caller might use
62
+ // to the canonical key likely present in the pricing data. Misses are harmless
63
+ // (resolution simply continues down the ladder).
64
+ const MODEL_ALIASES = {
65
+ 'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929',
66
+ 'claude-opus-4-1': 'claude-opus-4-1-20250805',
67
+ 'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
68
+ };
69
+
70
+ /**
71
+ * Parse MODEL_PRICE_OVERRIDES env (JSON object of
72
+ * { "<model>": { "input": <usd/1M>, "output": <usd/1M>, "context"?: N } }).
73
+ * Lets operators pin correct prices for models the registry doesn't know.
74
+ */
75
+ function _loadOverrides() {
76
+ const out = new Map();
77
+ const raw = process.env.MODEL_PRICE_OVERRIDES;
78
+ if (!raw) return out;
79
+ try {
80
+ const parsed = JSON.parse(raw);
81
+ for (const [name, info] of Object.entries(parsed)) {
82
+ if (info && typeof info.input === 'number' && typeof info.output === 'number') {
83
+ out.set(name.toLowerCase(), { context: 128000, ...info });
84
+ }
85
+ }
86
+ } catch (err) {
87
+ logger.warn({ err: err.message }, '[ModelRegistry] Failed to parse MODEL_PRICE_OVERRIDES');
88
+ }
89
+ return out;
90
+ }
91
+
60
92
  class ModelRegistry {
61
93
  constructor() {
62
94
  this.litellmPrices = {};
@@ -64,6 +96,7 @@ class ModelRegistry {
64
96
  this.loaded = false;
65
97
  this.lastFetch = 0;
66
98
  this.modelIndex = new Map();
99
+ this.overrides = _loadOverrides();
67
100
  }
68
101
 
69
102
  /**
@@ -255,40 +288,70 @@ class ModelRegistry {
255
288
  * @returns {Object} Cost info { input, output, context, ... }
256
289
  */
257
290
  getCost(modelName) {
258
- if (!modelName) return { ...DEFAULT_COST, source: 'default' };
291
+ if (!modelName) return { ...DEFAULT_COST, source: 'default', unknown: true };
259
292
 
260
- const normalizedName = modelName.toLowerCase();
293
+ const name = String(modelName).toLowerCase().trim();
294
+ const hit = this._resolveCost(name);
295
+ if (hit) return hit;
261
296
 
262
- // Direct lookup
263
- if (this.modelIndex.has(normalizedName)) {
264
- return this.modelIndex.get(normalizedName);
265
- }
297
+ // Nothing matched — report unknown rather than silently fabricating a price.
298
+ logger.debug({ model: modelName }, '[ModelRegistry] Model not found — cost unknown');
299
+ return { ...DEFAULT_COST, source: 'default', unknown: true };
300
+ }
266
301
 
267
- // Try common variations
268
- const variations = [
269
- normalizedName,
270
- normalizedName.replace('databricks-', ''),
271
- normalizedName.replace('azure/', ''),
272
- normalizedName.replace('bedrock/', ''),
273
- normalizedName.replace('anthropic.', ''),
274
- normalizedName.split('/').pop(),
275
- ];
276
-
277
- for (const variant of variations) {
278
- if (this.modelIndex.has(variant)) {
279
- return this.modelIndex.get(variant);
280
- }
302
+ /**
303
+ * Deterministic price resolution. Each step is exact (no bidirectional
304
+ * substring matching), and the only loose step (longest-prefix) is
305
+ * one-directional and length-bounded, so unrelated names can't false-match.
306
+ * Returns a cost object with a `resolution` tag, or null if nothing matched.
307
+ * @param {string} name - already lowercased/trimmed
308
+ */
309
+ _resolveCost(name) {
310
+ const tag = (value, resolution, matchedAs) => ({
311
+ ...value,
312
+ resolution,
313
+ ...(matchedAs && matchedAs !== name ? { matchedAs } : {}),
314
+ });
315
+
316
+ // 1. Operator overrides (exact) — ground truth.
317
+ if (this.overrides.has(name)) return tag({ ...this.overrides.get(name), source: 'override' }, 'override');
318
+
319
+ // 2. Exact registry hit.
320
+ if (this.modelIndex.has(name)) return tag(this.modelIndex.get(name), 'exact');
321
+
322
+ // 3. Provider-prefix strip (exact).
323
+ const stripped = [
324
+ name.replace(/^databricks-/, ''),
325
+ name.replace(/^azure\//, ''),
326
+ name.replace(/^bedrock\//, ''),
327
+ name.replace(/^anthropic\./, ''),
328
+ name.replace(/^openai\//, ''),
329
+ name.includes('/') ? name.split('/').pop() : null,
330
+ ].filter((v) => v && v !== name);
331
+ for (const v of stripped) {
332
+ if (this.overrides.has(v)) return tag({ ...this.overrides.get(v), source: 'override' }, 'prefix-strip', v);
333
+ if (this.modelIndex.has(v)) return tag(this.modelIndex.get(v), 'prefix-strip', v);
281
334
  }
282
335
 
283
- // Fuzzy match for partial names
336
+ // 4. Curated alias (exact).
337
+ const alias = MODEL_ALIASES[name];
338
+ if (alias && this.modelIndex.has(alias)) return tag(this.modelIndex.get(alias), 'alias', alias);
339
+
340
+ // 5. Date/version-suffix normalization (e.g. -20250929, -2025-09-29, -v2).
341
+ const dateless = name.replace(/[-@](\d{8}|\d{4}-\d{2}-\d{2}|v\d+)$/, '');
342
+ if (dateless !== name && this.modelIndex.has(dateless)) return tag(this.modelIndex.get(dateless), 'date-normalize', dateless);
343
+
344
+ // 6. Longest registry key that is a prefix of the requested name. Bounded so
345
+ // short keys can't grab unrelated names (e.g. "gpt-5.2-chat-2026" → "gpt-5.2-chat").
346
+ let best = null;
284
347
  for (const [key, value] of this.modelIndex.entries()) {
285
- if (key.includes(normalizedName) || normalizedName.includes(key)) {
286
- return value;
348
+ if (key.length >= 6 && name.startsWith(key) && (!best || key.length > best.key.length)) {
349
+ best = { key, value };
287
350
  }
288
351
  }
352
+ if (best) return tag(best.value, 'longest-prefix', best.key);
289
353
 
290
- logger.debug({ model: modelName }, '[ModelRegistry] Model not found, using default');
291
- return { ...DEFAULT_COST, source: 'default' };
354
+ return null;
292
355
  }
293
356
 
294
357
  /**
@@ -13,13 +13,18 @@ const { extractContent } = require('./complexity-analyzer');
13
13
  // Substring keywords found in file paths or instruction text.
14
14
  // Matched case-insensitively as raw substrings, so "auth" hits
15
15
  // "src/auth/login.ts" and "authentication".
16
+ // NOTE: keywords are matched as case-insensitive *substrings* against file
17
+ // paths, so overly generic terms cause false positives. 'session' and 'token'
18
+ // were removed because they match benign paths (src/sessions/*, tokenizer.js,
19
+ // token-budget.js) and were force-escalating ordinary requests to COMPLEX —
20
+ // real secrets/credentials are still covered by the keywords below.
16
21
  const PROTECTED_PATH_KEYWORDS = [
17
- 'auth', 'oauth', 'jwt', 'session', 'security', 'permission', 'rbac',
22
+ 'auth', 'oauth', 'jwt', 'security', 'permission', 'rbac',
18
23
  'payment', 'payments', 'billing', 'invoice', 'subscription',
19
24
  'migration', 'migrations', 'schema',
20
25
  'infra', 'terraform', 'kustomize', 'helm', 'kubernetes',
21
26
  '.github/workflows', '.env', 'secret', 'credential',
22
- 'api-key', 'api_key', 'apikey', 'token',
27
+ 'api-key', 'api_key', 'apikey',
23
28
  'webhook', 'admin',
24
29
  ];
25
30
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Session → Provider Affinity
3
+ *
4
+ * A multi-turn agentic conversation builds up tool_use / tool_result history
5
+ * whose tool-call IDs are formatted for the provider that produced them. If a
6
+ * later turn re-routes to a *different* provider (because per-turn complexity
7
+ * or risk changed), that provider rejects the orphaned tool linkage:
8
+ *
9
+ * Azure: 400 "No tool call found for function call output with call_id …"
10
+ * Moonshot: 400 "Invalid request: tool_call_id is not found"
11
+ *
12
+ * To prevent that, once a session has chosen a provider we keep subsequent
13
+ * turns on it *while the payload carries tool history*. Fresh turns (no tool
14
+ * state) still route normally, so per-turn tier routing is preserved.
15
+ *
16
+ * @module routing/session-affinity
17
+ */
18
+
19
+ const MAX_ENTRIES = 2000;
20
+ const TTL_MS = 60 * 60 * 1000; // 1 hour
21
+
22
+ /** @type {Map<string, {provider:string, model:string|null, tier:string|null, ts:number}>} */
23
+ const pins = new Map();
24
+
25
+ function _evictIfNeeded() {
26
+ if (pins.size <= MAX_ENTRIES) return;
27
+ // Map preserves insertion order — drop the oldest.
28
+ const oldest = pins.keys().next().value;
29
+ if (oldest !== undefined) pins.delete(oldest);
30
+ }
31
+
32
+ /**
33
+ * True when the payload contains an in-flight tool exchange — i.e. a prior
34
+ * assistant tool_use or a user tool_result. These are the turns whose
35
+ * tool-call IDs break if the provider changes.
36
+ * @param {object} payload
37
+ * @returns {boolean}
38
+ */
39
+ function payloadHasToolHistory(payload) {
40
+ const messages = payload?.messages;
41
+ if (!Array.isArray(messages)) return false;
42
+ for (const msg of messages) {
43
+ const content = msg?.content;
44
+ if (!Array.isArray(content)) continue;
45
+ for (const block of content) {
46
+ const t = block?.type;
47
+ if (t === "tool_use" || t === "tool_result") return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Return the pinned routing decision for a session, or null if none / expired.
55
+ * @param {string} sessionId
56
+ */
57
+ function getPinned(sessionId) {
58
+ if (!sessionId) return null;
59
+ const entry = pins.get(sessionId);
60
+ if (!entry) return null;
61
+ if (Date.now() - entry.ts > TTL_MS) {
62
+ pins.delete(sessionId);
63
+ return null;
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ /**
69
+ * Record the provider a session routed to, for reuse on later tool-bearing turns.
70
+ * @param {string} sessionId
71
+ * @param {{provider:string, model?:string|null, tier?:string|null}} decision
72
+ */
73
+ function setPinned(sessionId, decision) {
74
+ if (!sessionId || !decision?.provider) return;
75
+ // Refresh insertion order so active sessions aren't evicted.
76
+ pins.delete(sessionId);
77
+ pins.set(sessionId, {
78
+ provider: decision.provider,
79
+ model: decision.model ?? null,
80
+ tier: decision.tier ?? null,
81
+ ts: Date.now(),
82
+ });
83
+ _evictIfNeeded();
84
+ }
85
+
86
+ /** Test/maintenance helper. */
87
+ function _clear() {
88
+ pins.clear();
89
+ }
90
+
91
+ module.exports = {
92
+ payloadHasToolHistory,
93
+ getPinned,
94
+ setPinned,
95
+ _clear,
96
+ };
@@ -94,7 +94,9 @@ function init() {
94
94
  circuit_breaker_state TEXT,
95
95
  quality_score REAL,
96
96
  tokens_per_second REAL,
97
- cost_efficiency REAL
97
+ cost_efficiency REAL,
98
+ request_text TEXT,
99
+ response_text TEXT
98
100
  );
99
101
 
100
102
  CREATE INDEX IF NOT EXISTS idx_telemetry_provider
@@ -110,6 +112,15 @@ function init() {
110
112
  ON routing_telemetry(session_id, timestamp);
111
113
  `);
112
114
 
115
+ // Migration: add columns to pre-existing tables (CREATE TABLE IF NOT EXISTS
116
+ // won't add them to a DB created before these columns existed).
117
+ const existingCols = new Set(db.prepare("PRAGMA table_info(routing_telemetry)").all().map((c) => c.name));
118
+ for (const col of ["request_text", "response_text"]) {
119
+ if (!existingCols.has(col)) {
120
+ db.exec(`ALTER TABLE routing_telemetry ADD COLUMN ${col} TEXT`);
121
+ }
122
+ }
123
+
113
124
  logger.info({ dbPath }, "Routing telemetry database initialised");
114
125
  return true;
115
126
  } catch (err) {
@@ -163,14 +174,14 @@ function record(data) {
163
174
  provider, model, routing_method, was_fallback, output_tokens,
164
175
  latency_ms, status_code, error_type, cost_usd, tool_calls_made,
165
176
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
166
- cost_efficiency
177
+ cost_efficiency, request_text, response_text
167
178
  ) VALUES (
168
179
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
169
180
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
170
181
  @provider, @model, @routing_method, @was_fallback, @output_tokens,
171
182
  @latency_ms, @status_code, @error_type, @cost_usd, @tool_calls_made,
172
183
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
173
- @cost_efficiency
184
+ @cost_efficiency, @request_text, @response_text
174
185
  )`
175
186
  );
176
187
  if (!insert) return;
@@ -201,6 +212,8 @@ function record(data) {
201
212
  quality_score: data.quality_score ?? null,
202
213
  tokens_per_second: data.tokens_per_second ?? null,
203
214
  cost_efficiency: data.cost_efficiency ?? null,
215
+ request_text: data.request_text ?? null,
216
+ response_text: data.response_text ?? null,
204
217
  });
205
218
  } catch (err) {
206
219
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -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