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.
- package/README.md +49 -15
- package/install.sh +21 -5
- package/package.json +4 -2
- package/public/dashboard.html +13 -1
- package/scripts/check-native.js +97 -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/clients/databricks.js +215 -4
- package/src/clients/openrouter-utils.js +19 -27
- package/src/clients/prompt-cache-injection.js +49 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +32 -0
- package/src/context/caveman.js +94 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/context/tool-dedup.js +95 -0
- package/src/context/tool-result-compressor.js +106 -0
- package/src/dashboard/api.js +69 -18
- package/src/orchestrator/bypass.js +135 -0
- package/src/orchestrator/index.js +33 -2
- package/src/routing/index.js +47 -0
- package/src/routing/model-registry.js +89 -26
- package/src/routing/risk-analyzer.js +7 -2
- package/src/routing/session-affinity.js +96 -0
- package/src/routing/telemetry.js +16 -3
- 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/.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 (
|
|
1366
|
-
|
|
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
|
package/src/routing/index.js
CHANGED
|
@@ -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
|
|
293
|
+
const name = String(modelName).toLowerCase().trim();
|
|
294
|
+
const hit = this._resolveCost(name);
|
|
295
|
+
if (hit) return hit;
|
|
261
296
|
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
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.
|
|
286
|
-
|
|
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
|
-
|
|
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', '
|
|
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',
|
|
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
|
+
};
|
package/src/routing/telemetry.js
CHANGED
|
@@ -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 };
|
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
|