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.
Files changed (61) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +10 -4
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/mcp-broker.js +149 -0
  17. package/src/api/middleware/budget-enforcer.js +77 -7
  18. package/src/api/middleware/budget.js +36 -2
  19. package/src/api/openai-router.js +125 -4
  20. package/src/api/providers-handler.js +16 -0
  21. package/src/api/router.js +67 -103
  22. package/src/auth-mode.js +40 -4
  23. package/src/budget/index.js +86 -0
  24. package/src/cache/embeddings.js +125 -35
  25. package/src/cache/onnx-embedder.js +92 -0
  26. package/src/clients/databricks.js +285 -21
  27. package/src/clients/gpt-utils.js +83 -7
  28. package/src/clients/health-probe.js +157 -0
  29. package/src/clients/provider-capabilities.js +27 -0
  30. package/src/config/index.js +13 -1
  31. package/src/context/compression.js +127 -19
  32. package/src/dashboard/api.js +1 -0
  33. package/src/observability/metrics.js +52 -0
  34. package/src/observability/otel.js +180 -0
  35. package/src/orchestrator/azure-responses-sse.js +223 -0
  36. package/src/orchestrator/index.js +219 -136
  37. package/src/orchestrator/passthrough-stream.js +7 -1
  38. package/src/orchestrator/sse-transformer.js +62 -3
  39. package/src/routing/bandit.js +39 -0
  40. package/src/routing/capabilities.js +88 -0
  41. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  42. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  43. package/src/routing/capability-seeds/family.js +105 -0
  44. package/src/routing/capability-seeds/models-dev.js +64 -0
  45. package/src/routing/capability-seeds/normalize.js +160 -0
  46. package/src/routing/capability-seeds/registry.js +125 -0
  47. package/src/routing/capability-seeds/sources.js +69 -0
  48. package/src/routing/capability-seeds/swebench.js +66 -0
  49. package/src/routing/decide.js +149 -0
  50. package/src/routing/deescalator.js +37 -0
  51. package/src/routing/index.js +202 -86
  52. package/src/routing/model-registry.js +25 -0
  53. package/src/routing/model-slots.js +65 -0
  54. package/src/routing/model-tiers.js +2 -0
  55. package/src/routing/ope.js +255 -0
  56. package/src/routing/openai-model-slots.js +82 -0
  57. package/src/routing/shortfall.js +290 -0
  58. package/src/routing/stuck-detector.js +127 -0
  59. package/src/routing/telemetry.js +11 -2
  60. package/src/server.js +41 -3
  61. 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
  };
@@ -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
- const splitIndex = Math.max(0, messages.length - opts.keepRecentTurns);
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.length < 200) {
134
- keyPoints.push(`User: ${content}`);
135
- } else {
136
- // Compress long user messages
137
- keyPoints.push(`User: ${content.substring(0, 150)}...`);
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 toolUses = extractToolUses(msg);
145
- if (toolUses.length > 0) {
146
- keyPoints.push(`Assistant used tools: ${toolUses.join(', ')}`);
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
- if (content.length > 20 && content.length < 200) {
151
- keyPoints.push(`Assistant: ${content}`);
152
- } else if (content.length >= 200) {
153
- keyPoints.push(`Assistant: ${content.substring(0, 150)}...`);
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 tool names used in message
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} Tool names
496
+ * @returns {Array<{id: string, name: string, input: Object}>}
389
497
  */
390
- function extractToolUses(message) {
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
  /**
@@ -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