lynkr 9.14.2 → 9.14.7
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 +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +10 -4
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/tools/web-search-exec.js +182 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic circuit-breaker health probe (ROUTING-NOTES §1 gap audit).
|
|
3
|
+
*
|
|
4
|
+
* Problem: cockatiel's halfOpenAfter is a pure timer — after it elapses, the
|
|
5
|
+
* NEXT LIVE USER REQUEST is the recovery probe. A user pays the latency/cost
|
|
6
|
+
* of testing a dead provider, and a provider with no traffic never recovers.
|
|
7
|
+
*
|
|
8
|
+
* Fix (the pattern LiteLLM ships as health-check-driven routing): a
|
|
9
|
+
* background loop that, for every breaker currently OPEN or HALF_OPEN, runs
|
|
10
|
+
* a cheap synthetic request *through the breaker* (`breaker.execute(probe)`)
|
|
11
|
+
* on a schedule. A success closes the circuit via cockatiel's normal
|
|
12
|
+
* half-open transition; a failure re-opens it. Healthy breakers are never
|
|
13
|
+
* probed — the loop is zero-cost when everything is up.
|
|
14
|
+
*
|
|
15
|
+
* Probes are registered per provider name (the breaker registry key).
|
|
16
|
+
* Built-in probes cover the local providers (ollama / llamacpp / lmstudio),
|
|
17
|
+
* whose cheap GET endpoints are well-known, free, and exactly where the
|
|
18
|
+
* dead-upstream-hang problem historically lived. Cloud providers can be
|
|
19
|
+
* added via registerHealthProbe(name, fn); without one, their recovery
|
|
20
|
+
* stays as before (next live request) — no behavior regression.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const config = require('../config');
|
|
24
|
+
const logger = require('../logger');
|
|
25
|
+
const { getCockatielRegistry } = require('./resilience');
|
|
26
|
+
|
|
27
|
+
const PROBE_INTERVAL_MS = Number.parseInt(process.env.LYNKR_HEALTH_PROBE_INTERVAL_MS, 10) || 30_000;
|
|
28
|
+
const PROBE_TIMEOUT_MS = Number.parseInt(process.env.LYNKR_HEALTH_PROBE_TIMEOUT_MS, 10) || 5_000;
|
|
29
|
+
const ENABLED = process.env.LYNKR_HEALTH_PROBE_ENABLED !== 'false';
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, () => Promise<void>>} providerName → probe fn (throws on unhealthy) */
|
|
32
|
+
const probes = new Map();
|
|
33
|
+
|
|
34
|
+
async function _cheapGet(url, headers = {}) {
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
method: 'GET',
|
|
37
|
+
headers,
|
|
38
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new Error(`health probe ${url} → ${response.status}`);
|
|
42
|
+
}
|
|
43
|
+
// Drain the (small) body so the socket is released cleanly.
|
|
44
|
+
await response.arrayBuffer().catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _registerBuiltins() {
|
|
48
|
+
if (config.ollama?.endpoint) {
|
|
49
|
+
probes.set('ollama', () => _cheapGet(`${config.ollama.endpoint}/api/tags`));
|
|
50
|
+
}
|
|
51
|
+
if (config.llamacpp?.endpoint) {
|
|
52
|
+
const headers = config.llamacpp.apiKey
|
|
53
|
+
? { Authorization: `Bearer ${config.llamacpp.apiKey}` }
|
|
54
|
+
: {};
|
|
55
|
+
probes.set('llamacpp', () => _cheapGet(`${config.llamacpp.endpoint}/v1/models`, headers));
|
|
56
|
+
}
|
|
57
|
+
if (config.lmstudio?.endpoint) {
|
|
58
|
+
const headers = config.lmstudio.apiKey
|
|
59
|
+
? { Authorization: `Bearer ${config.lmstudio.apiKey}` }
|
|
60
|
+
: {};
|
|
61
|
+
probes.set('lmstudio', () => _cheapGet(`${config.lmstudio.endpoint}/v1/models`, headers));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register (or override) a synthetic probe for a provider. The function must
|
|
67
|
+
* resolve when the provider is healthy and throw/reject when it is not.
|
|
68
|
+
* @param {string} providerName — must match the circuit breaker registry key
|
|
69
|
+
* @param {() => Promise<void>} probeFn
|
|
70
|
+
*/
|
|
71
|
+
function registerHealthProbe(providerName, probeFn) {
|
|
72
|
+
probes.set(providerName, probeFn);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class HealthProber {
|
|
76
|
+
constructor() {
|
|
77
|
+
this.timer = null;
|
|
78
|
+
this.stats = { sweeps: 0, probesSent: 0, recoveries: 0, failures: 0, lastSweepAt: null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
start() {
|
|
82
|
+
if (this.timer || !ENABLED) return;
|
|
83
|
+
_registerBuiltins();
|
|
84
|
+
this.timer = setInterval(() => {
|
|
85
|
+
this.sweep().catch((err) => {
|
|
86
|
+
logger.debug({ error: err.message }, '[HealthProbe] sweep error');
|
|
87
|
+
});
|
|
88
|
+
}, PROBE_INTERVAL_MS);
|
|
89
|
+
// Never keep the process alive just to probe.
|
|
90
|
+
this.timer.unref?.();
|
|
91
|
+
logger.info(
|
|
92
|
+
{ intervalMs: PROBE_INTERVAL_MS, providers: [...probes.keys()] },
|
|
93
|
+
'[HealthProbe] Synthetic circuit-breaker probing started'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
stop() {
|
|
98
|
+
if (this.timer) {
|
|
99
|
+
clearInterval(this.timer);
|
|
100
|
+
this.timer = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One probing pass: probe every registered provider whose breaker is not
|
|
106
|
+
* closed. Runs probes through the breaker itself so state transitions use
|
|
107
|
+
* cockatiel's own half-open machinery.
|
|
108
|
+
*/
|
|
109
|
+
async sweep() {
|
|
110
|
+
this.stats.sweeps += 1;
|
|
111
|
+
this.stats.lastSweepAt = Date.now();
|
|
112
|
+
const registry = getCockatielRegistry();
|
|
113
|
+
const work = [];
|
|
114
|
+
for (const [providerName, probeFn] of probes) {
|
|
115
|
+
const breaker = registry.breakers.get(providerName);
|
|
116
|
+
if (!breaker || breaker.state === 'CLOSED') continue;
|
|
117
|
+
work.push(
|
|
118
|
+
(async () => {
|
|
119
|
+
this.stats.probesSent += 1;
|
|
120
|
+
try {
|
|
121
|
+
await breaker.execute(probeFn);
|
|
122
|
+
this.stats.recoveries += 1;
|
|
123
|
+
logger.info({ provider: providerName }, '[HealthProbe] Provider recovered via synthetic probe — circuit closed without a live request paying for the test');
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Expected while the provider is still down (or the circuit is
|
|
126
|
+
// OPEN and not yet past halfOpenAfter, where execute rejects
|
|
127
|
+
// immediately without dialing). Stay quiet; the breaker's own
|
|
128
|
+
// state logging covers transitions.
|
|
129
|
+
this.stats.failures += 1;
|
|
130
|
+
logger.debug({ provider: providerName, error: err.message }, '[HealthProbe] probe failed');
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
await Promise.all(work);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getStatus() {
|
|
139
|
+
return {
|
|
140
|
+
enabled: ENABLED,
|
|
141
|
+
running: !!this.timer,
|
|
142
|
+
intervalMs: PROBE_INTERVAL_MS,
|
|
143
|
+
registeredProviders: [...probes.keys()],
|
|
144
|
+
...this.stats,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let prober = null;
|
|
150
|
+
|
|
151
|
+
/** @returns {HealthProber} singleton */
|
|
152
|
+
function getHealthProber() {
|
|
153
|
+
if (!prober) prober = new HealthProber();
|
|
154
|
+
return prober;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { getHealthProber, registerHealthProbe };
|
|
@@ -32,8 +32,35 @@ function getThinkingBehavior(providerType, model) {
|
|
|
32
32
|
return "none";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Anthropic-shaped `thinking` request param to send upstream, honoring an
|
|
37
|
+
* explicit client request and otherwise defaulting to disabled.
|
|
38
|
+
*
|
|
39
|
+
* Confirmed live (2026-08-27) against both endpoints directly: Baidu
|
|
40
|
+
* Qianfan's `glm-5.2` and Moonshot's Kimi (`kimi-k3`) both emit verbose
|
|
41
|
+
* `reasoning_content` on EVERY call by default, sharing the same token
|
|
42
|
+
* budget as the visible answer — with no instruction at all, a baseline
|
|
43
|
+
* call to either returned ~500-600 chars of reasoning_content, an EMPTY
|
|
44
|
+
* `content`, and `finish_reason:"length"` (the whole max_tokens budget
|
|
45
|
+
* spent on reasoning, none left for the actual answer). The GLM/Qwen
|
|
46
|
+
* convention `enable_thinking:false` does NOT suppress this on either
|
|
47
|
+
* endpoint — only this Anthropic-shaped `{type:"disabled"}` param does
|
|
48
|
+
* (also confirmed live, on both). Not a caveman-specific issue — the same
|
|
49
|
+
* budget waste happens on every request to these two providers; caveman's
|
|
50
|
+
* elaborate constraint set just makes it worse, since the model has more to
|
|
51
|
+
* visibly deliberate against.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} body - incoming Anthropic-format request body
|
|
54
|
+
* @returns {Object} the `thinking` param to send upstream
|
|
55
|
+
*/
|
|
56
|
+
function resolveThinkingParam(body) {
|
|
57
|
+
if (body?.thinking && typeof body.thinking === "object") return body.thinking;
|
|
58
|
+
return { type: "disabled" };
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
module.exports = {
|
|
36
62
|
supportsNativeThinking,
|
|
37
63
|
supportsReasoningContent,
|
|
38
64
|
getThinkingBehavior,
|
|
65
|
+
resolveThinkingParam,
|
|
39
66
|
};
|
package/src/config/index.js
CHANGED
|
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
|
|
|
62
62
|
return path.resolve(normalised);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu"]);
|
|
65
|
+
const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu", "fireworks"]);
|
|
66
66
|
const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
|
|
67
67
|
|
|
68
68
|
// Validate MODEL_PROVIDER early with a clear error message
|
|
@@ -153,6 +153,11 @@ const baiduApiKey = process.env.BAIDU_API_KEY?.trim() || null;
|
|
|
153
153
|
const baiduEndpoint = process.env.BAIDU_ENDPOINT?.trim() || "https://qianfan.baidubce.com/v2/chat/completions";
|
|
154
154
|
const baiduModel = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
|
|
155
155
|
|
|
156
|
+
// Fireworks AI configuration - OpenAI-compatible serverless inference API
|
|
157
|
+
const fireworksApiKey = process.env.FIREWORKS_API_KEY?.trim() || null;
|
|
158
|
+
const fireworksEndpoint = process.env.FIREWORKS_ENDPOINT?.trim() || "https://api.fireworks.ai/inference/v1/chat/completions";
|
|
159
|
+
const fireworksModel = process.env.FIREWORKS_MODEL?.trim() || "accounts/fireworks/models/kimi-k2-instruct-0905";
|
|
160
|
+
|
|
156
161
|
// Vertex AI (Google Gemini) configuration
|
|
157
162
|
const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
|
|
158
163
|
const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
|
|
@@ -662,6 +667,11 @@ var config = {
|
|
|
662
667
|
endpoint: baiduEndpoint,
|
|
663
668
|
model: baiduModel,
|
|
664
669
|
},
|
|
670
|
+
fireworks: {
|
|
671
|
+
apiKey: fireworksApiKey,
|
|
672
|
+
endpoint: fireworksEndpoint,
|
|
673
|
+
model: fireworksModel,
|
|
674
|
+
},
|
|
665
675
|
codex: {
|
|
666
676
|
enabled: process.env.CODEX_ENABLED !== "false",
|
|
667
677
|
binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null,
|
|
@@ -1125,6 +1135,8 @@ function reloadConfig() {
|
|
|
1125
1135
|
config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
|
|
1126
1136
|
config.baidu.apiKey = process.env.BAIDU_API_KEY?.trim() || null;
|
|
1127
1137
|
config.baidu.model = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
|
|
1138
|
+
config.fireworks.apiKey = process.env.FIREWORKS_API_KEY?.trim() || null;
|
|
1139
|
+
config.fireworks.model = process.env.FIREWORKS_MODEL?.trim() || "accounts/fireworks/models/kimi-k2-instruct-0905";
|
|
1128
1140
|
|
|
1129
1141
|
// Model provider settings
|
|
1130
1142
|
const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
|
|
@@ -40,14 +40,50 @@ function compressHistory(messages, options = {}) {
|
|
|
40
40
|
keepRecentTurns: options.keepRecentTurns ?? config.historyCompression?.keepRecentTurns ?? 10,
|
|
41
41
|
summarizeOlder: options.summarizeOlder ?? config.historyCompression?.summarizeOlder ?? true,
|
|
42
42
|
enabled: options.enabled ?? config.historyCompression?.enabled ?? true,
|
|
43
|
+
// Model token budget (e.g. effectiveMax from the orchestrator). When set,
|
|
44
|
+
// the verbatim-recent window grows to USE the budget instead of a fixed
|
|
45
|
+
// message count. Live incident: a 935-message session against a 128k
|
|
46
|
+
// model kept only 10 recent messages (~56k tokens) while 108.8k tokens
|
|
47
|
+
// of budget were available — ~50k tokens of context thrown away, giving
|
|
48
|
+
// the model amnesia about its own recent edits.
|
|
49
|
+
budgetTokens: options.budgetTokens ?? null,
|
|
43
50
|
};
|
|
44
51
|
|
|
45
52
|
if (!opts.enabled) {
|
|
46
53
|
return messages; // Return uncompressed if disabled
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
// Recent-window size: fixed count by default; budget-driven when a model
|
|
57
|
+
// token budget is provided. RECENT_BUDGET_FRACTION reserves the rest of
|
|
58
|
+
// the budget for the summary block, system prompt, tools, and the
|
|
59
|
+
// response itself.
|
|
60
|
+
const RECENT_BUDGET_FRACTION = 0.6;
|
|
61
|
+
const CHARS_PER_TOKEN = 4;
|
|
62
|
+
let keepRecent = opts.keepRecentTurns;
|
|
63
|
+
if (Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0) {
|
|
64
|
+
const targetChars = opts.budgetTokens * CHARS_PER_TOKEN * RECENT_BUDGET_FRACTION;
|
|
65
|
+
let accumulated = 0;
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
68
|
+
accumulated += estimateMessagesSize([messages[i]]);
|
|
69
|
+
if (accumulated > targetChars) break;
|
|
70
|
+
count++;
|
|
71
|
+
}
|
|
72
|
+
keepRecent = Math.max(opts.keepRecentTurns, count);
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
// Calculate split point
|
|
50
|
-
|
|
76
|
+
let splitIndex = Math.max(0, messages.length - keepRecent);
|
|
77
|
+
|
|
78
|
+
// Tool-exchange boundary alignment: never let the recent window START with
|
|
79
|
+
// a tool_result whose paired tool_use just got summarized away — providers
|
|
80
|
+
// reject orphaned tool_result blocks ("unexpected tool_use_id"). Pull the
|
|
81
|
+
// split earlier until the boundary is clean.
|
|
82
|
+
const startsWithToolResult = (msg) =>
|
|
83
|
+
Array.isArray(msg?.content) && msg.content.some((b) => b?.type === 'tool_result');
|
|
84
|
+
while (splitIndex > 0 && startsWithToolResult(messages[splitIndex])) {
|
|
85
|
+
splitIndex--;
|
|
86
|
+
}
|
|
51
87
|
|
|
52
88
|
if (splitIndex === 0) {
|
|
53
89
|
// All messages are recent, no compression needed
|
|
@@ -126,31 +162,56 @@ function summarizeOldHistory(messages) {
|
|
|
126
162
|
let hasUserInput = false;
|
|
127
163
|
let hasAssistantOutput = false;
|
|
128
164
|
|
|
165
|
+
// Index tool_result blocks by tool_use_id so each tool_use below can carry
|
|
166
|
+
// a one-line recap of what it actually found. Previously this summary kept
|
|
167
|
+
// only the tool NAME ("Assistant used tools: bash") and threw away the
|
|
168
|
+
// result entirely — which meant a long tool-calling session forgot its own
|
|
169
|
+
// diagnosis every time it aged past the recent-turns window and had to
|
|
170
|
+
// re-derive it from scratch (live symptom: an agent stuck re-reading the
|
|
171
|
+
// same file and re-fetching the same issue instead of ever writing a fix).
|
|
172
|
+
const resultByToolUseId = new Map();
|
|
173
|
+
for (const msg of messages) {
|
|
174
|
+
if (!Array.isArray(msg.content)) continue;
|
|
175
|
+
for (const block of msg.content) {
|
|
176
|
+
if (block?.type === 'tool_result' && block.tool_use_id) {
|
|
177
|
+
resultByToolUseId.set(block.tool_use_id, extractResultSnippet(block));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
129
182
|
for (const msg of messages) {
|
|
130
183
|
if (msg.role === 'user') {
|
|
131
184
|
hasUserInput = true;
|
|
132
185
|
const content = extractTextContent(msg);
|
|
133
|
-
if (content
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
keyPoints.push(`User: ${content
|
|
186
|
+
if (content) {
|
|
187
|
+
// compressText keeps head AND tail (unlike a flat substring(0, N)),
|
|
188
|
+
// which matters here too: the actionable part of a long user
|
|
189
|
+
// message is often at the end ("...so please fix X and open a PR").
|
|
190
|
+
keyPoints.push(`User: ${compressText(content, 200)}`);
|
|
138
191
|
}
|
|
139
192
|
} else if (msg.role === 'assistant') {
|
|
140
193
|
hasAssistantOutput = true;
|
|
141
194
|
const content = extractTextContent(msg);
|
|
142
195
|
|
|
143
|
-
// Extract tool uses
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
196
|
+
// Extract tool uses WITH their outcome, not just the tool name.
|
|
197
|
+
const toolUseBlocks = extractToolUseBlocks(msg);
|
|
198
|
+
for (const use of toolUseBlocks) {
|
|
199
|
+
const argsSnippet = summarizeToolArgs(use.input);
|
|
200
|
+
const resultSnippet = resultByToolUseId.get(use.id);
|
|
201
|
+
keyPoints.push(
|
|
202
|
+
resultSnippet
|
|
203
|
+
? `Assistant called ${use.name}(${argsSnippet}) → ${resultSnippet}`
|
|
204
|
+
: `Assistant called ${use.name}(${argsSnippet})`
|
|
205
|
+
);
|
|
147
206
|
}
|
|
148
207
|
|
|
149
|
-
// Add assistant text if meaningful
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
208
|
+
// Add assistant text if meaningful. compressText (head+tail) instead
|
|
209
|
+
// of a head-only substring(0, 150) — conclusions ("Cause: ...",
|
|
210
|
+
// "Findings: ...") are usually written at the END of a reasoning
|
|
211
|
+
// turn, so a head-only cut was silently discarding exactly the part
|
|
212
|
+
// that mattered for remembering what was already diagnosed.
|
|
213
|
+
if (content.length > 20) {
|
|
214
|
+
keyPoints.push(`Assistant: ${compressText(content, 300)}`);
|
|
154
215
|
}
|
|
155
216
|
}
|
|
156
217
|
}
|
|
@@ -168,6 +229,51 @@ function summarizeOldHistory(messages) {
|
|
|
168
229
|
};
|
|
169
230
|
}
|
|
170
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Short, single-line recap of a tool_result's content — used so the
|
|
234
|
+
* compacted history shows what a tool call actually found, not just that it
|
|
235
|
+
* was called. Reuses compressText's head+tail truncation.
|
|
236
|
+
*
|
|
237
|
+
* @param {Object} block - tool_result block
|
|
238
|
+
* @returns {string} snippet, or '' if the result had no usable text
|
|
239
|
+
*/
|
|
240
|
+
function extractResultSnippet(block) {
|
|
241
|
+
const RESULT_SNIPPET_MAX = 160;
|
|
242
|
+
let text = '';
|
|
243
|
+
if (typeof block.content === 'string') {
|
|
244
|
+
text = block.content;
|
|
245
|
+
} else if (Array.isArray(block.content)) {
|
|
246
|
+
text = block.content
|
|
247
|
+
.filter(item => typeof item === 'string' || item?.type === 'text')
|
|
248
|
+
.map(item => (typeof item === 'string' ? item : item.text || ''))
|
|
249
|
+
.join(' ');
|
|
250
|
+
}
|
|
251
|
+
text = text.trim();
|
|
252
|
+
if (!text) return '';
|
|
253
|
+
const prefix = block.is_error ? 'error: ' : '';
|
|
254
|
+
return prefix + compressText(text, RESULT_SNIPPET_MAX);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Compact, single-line rendering of a tool_use's arguments for the summary
|
|
259
|
+
* (e.g. `Read(file_path=".../types.go", offset=390)`), not the full JSON.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} input - tool_use input object
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function summarizeToolArgs(input) {
|
|
265
|
+
const ARGS_SNIPPET_MAX = 80;
|
|
266
|
+
if (!input || typeof input !== 'object') return '';
|
|
267
|
+
try {
|
|
268
|
+
const flat = Object.entries(input)
|
|
269
|
+
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
270
|
+
.join(', ');
|
|
271
|
+
return compressText(flat, ARGS_SNIPPET_MAX);
|
|
272
|
+
} catch {
|
|
273
|
+
return '';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
171
277
|
/**
|
|
172
278
|
* Compress a single message
|
|
173
279
|
*
|
|
@@ -382,17 +488,19 @@ function extractTextContent(message) {
|
|
|
382
488
|
}
|
|
383
489
|
|
|
384
490
|
/**
|
|
385
|
-
* Extract
|
|
491
|
+
* Extract tool_use blocks (id/name/input) from a message — the fuller
|
|
492
|
+
* counterpart to extractToolUses(), which only returns names. Used by
|
|
493
|
+
* summarizeOldHistory() to pair each call with its outcome.
|
|
386
494
|
*
|
|
387
495
|
* @param {Object} message - Message object
|
|
388
|
-
* @returns {Array
|
|
496
|
+
* @returns {Array<{id: string, name: string, input: Object}>}
|
|
389
497
|
*/
|
|
390
|
-
function
|
|
498
|
+
function extractToolUseBlocks(message) {
|
|
391
499
|
if (!Array.isArray(message.content)) return [];
|
|
392
500
|
|
|
393
501
|
return message.content
|
|
394
502
|
.filter(block => block.type === 'tool_use')
|
|
395
|
-
.map(block => block.name);
|
|
503
|
+
.map(block => ({ id: block.id, name: block.name, input: block.input }));
|
|
396
504
|
}
|
|
397
505
|
|
|
398
506
|
/**
|
package/src/dashboard/api.js
CHANGED
|
@@ -17,6 +17,7 @@ function providerMeta() {
|
|
|
17
17
|
'azure-openai': { type: 'cloud', configured: !!(c.azureOpenAI?.endpoint && c.azureOpenAI?.apiKey) },
|
|
18
18
|
vertex: { type: 'cloud', configured: !!c.vertex?.projectId },
|
|
19
19
|
moonshot: { type: 'cloud', configured: !!c.moonshot?.apiKey },
|
|
20
|
+
fireworks: { type: 'cloud', configured: !!c.fireworks?.apiKey },
|
|
20
21
|
ollama: { type: 'local', configured: !!c.ollama?.endpoint },
|
|
21
22
|
llamacpp: { type: 'local', configured: !!c.llamacpp?.endpoint },
|
|
22
23
|
lmstudio: { type: 'local', configured: !!c.lmstudio?.endpoint },
|
|
@@ -29,6 +29,20 @@ class MetricsCollector {
|
|
|
29
29
|
this.budgetBlocks = 0;
|
|
30
30
|
this.rateLimitBlocks = 0;
|
|
31
31
|
|
|
32
|
+
// Loop-guard interventions (cross-request tool dedup): warnings injected
|
|
33
|
+
// into model context and force-terminated turns. Non-zero numbers here
|
|
34
|
+
// mean Lynkr actively altered agent behavior — surfaced so operators
|
|
35
|
+
// don't need log forensics to discover it.
|
|
36
|
+
this.loopGuardWarnings = 0;
|
|
37
|
+
this.loopGuardTerminations = 0;
|
|
38
|
+
|
|
39
|
+
// History compression: how often and how hard conversations are being
|
|
40
|
+
// squeezed to fit the model's context window. A high maxPercentage means
|
|
41
|
+
// models are seeing a small fraction of their sessions.
|
|
42
|
+
this.historyCompressions = 0;
|
|
43
|
+
this.historyCompressionLastPct = 0;
|
|
44
|
+
this.historyCompressionMaxPct = 0;
|
|
45
|
+
|
|
32
46
|
// API metrics
|
|
33
47
|
this.databricksRequests = 0;
|
|
34
48
|
this.databricksErrors = 0;
|
|
@@ -120,6 +134,28 @@ class MetricsCollector {
|
|
|
120
134
|
this.rateLimitBlocks++;
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Record a loop-guard intervention.
|
|
139
|
+
* @param {'warn'|'terminate'} kind
|
|
140
|
+
*/
|
|
141
|
+
recordLoopGuard(kind) {
|
|
142
|
+
// 'observed' is the only kind emitted since the guard went observe-only;
|
|
143
|
+
// 'terminate' is kept as a counter bucket for any historical dashboards.
|
|
144
|
+
if (kind === 'terminate') this.loopGuardTerminations++;
|
|
145
|
+
else this.loopGuardWarnings++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Record a history-compression event.
|
|
150
|
+
* @param {number} percentage - percent of chars removed (0-100)
|
|
151
|
+
*/
|
|
152
|
+
recordHistoryCompression(percentage) {
|
|
153
|
+
this.historyCompressions++;
|
|
154
|
+
const pct = Number(percentage) || 0;
|
|
155
|
+
this.historyCompressionLastPct = pct;
|
|
156
|
+
if (pct > this.historyCompressionMaxPct) this.historyCompressionMaxPct = pct;
|
|
157
|
+
}
|
|
158
|
+
|
|
123
159
|
/**
|
|
124
160
|
* Record Databricks API call
|
|
125
161
|
*/
|
|
@@ -222,6 +258,13 @@ class MetricsCollector {
|
|
|
222
258
|
budget_blocks_total: this.budgetBlocks,
|
|
223
259
|
rate_limit_blocks_total: this.rateLimitBlocks,
|
|
224
260
|
|
|
261
|
+
// Loop guard + history compression (context interventions)
|
|
262
|
+
loop_guard_warnings_total: this.loopGuardWarnings,
|
|
263
|
+
loop_guard_terminations_total: this.loopGuardTerminations,
|
|
264
|
+
history_compressions_total: this.historyCompressions,
|
|
265
|
+
history_compression_last_pct: this.historyCompressionLastPct,
|
|
266
|
+
history_compression_max_pct: this.historyCompressionMaxPct,
|
|
267
|
+
|
|
225
268
|
// API
|
|
226
269
|
databricks_requests_total: this.databricksRequests,
|
|
227
270
|
databricks_errors_total: this.databricksErrors,
|
|
@@ -332,6 +375,15 @@ class MetricsCollector {
|
|
|
332
375
|
metric("http_request_duration_ms", "summary", "HTTP request latency in ms", metrics.latency_ms.p95, { quantile: "0.95" });
|
|
333
376
|
metric("http_request_duration_ms", "summary", "HTTP request latency in ms", metrics.latency_ms.p99, { quantile: "0.99" });
|
|
334
377
|
|
|
378
|
+
// OTel GenAI semantic-convention aliases (dots → underscores, per
|
|
379
|
+
// Prometheus conversion rules). Same underlying counters as above,
|
|
380
|
+
// named so semconv-aware dashboards/tooling find them.
|
|
381
|
+
metric("gen_ai_client_token_usage_total", "counter", "GenAI tokens consumed (OTel semconv)", metrics.tokens_input_total, { gen_ai_token_type: "input" });
|
|
382
|
+
metric("gen_ai_client_token_usage_total", "counter", "GenAI tokens consumed (OTel semconv)", metrics.tokens_output_total, { gen_ai_token_type: "output" });
|
|
383
|
+
metric("gen_ai_client_operation_duration_ms", "summary", "GenAI operation duration (OTel semconv)", metrics.latency_ms.median, { quantile: "0.5" });
|
|
384
|
+
metric("gen_ai_client_operation_duration_ms", "summary", "GenAI operation duration (OTel semconv)", metrics.latency_ms.p95, { quantile: "0.95" });
|
|
385
|
+
metric("gen_ai_client_operation_duration_ms", "summary", "GenAI operation duration (OTel semconv)", metrics.latency_ms.p99, { quantile: "0.99" });
|
|
386
|
+
|
|
335
387
|
return lines.join("\n");
|
|
336
388
|
}
|
|
337
389
|
|