lynkr 9.14.1 → 9.14.3

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 (96) 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 +16 -22
  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/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
@@ -13,14 +13,13 @@ const { applyToonCompression } = require("../context/toon");
13
13
  const { applyGcfCompression } = require("../context/gcf");
14
14
  const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom");
15
15
  const { createAuditLogger } = require("../logger/audit-logger");
16
- const { getResolvedIp, runWithDnsContext } = require("../clients/dns-logger");
17
16
  const { getShuttingDown } = require("../api/health");
18
17
  const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
19
18
  const { detectBypass, buildBypassResponse } = require("./bypass");
20
19
  const crypto = require("crypto");
21
- const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
20
+ const { getSemanticCache } = require("../cache/semantic");
22
21
  const { areSimilarToolCalls } = require("../clients/gpt-utils");
23
- const { getModelRegistrySync } = require("../routing/model-registry");
22
+ const { getMetricsCollector } = require("../observability/metrics");
24
23
  const sessionAffinity = require("../routing/session-affinity");
25
24
 
26
25
  /**
@@ -60,6 +59,8 @@ function getDestinationUrl(providerType) {
60
59
  return config.moonshot?.endpoint ?? 'unknown';
61
60
  case 'baidu':
62
61
  return config.baidu?.endpoint ?? 'unknown';
62
+ case 'fireworks':
63
+ return config.fireworks?.endpoint ?? 'unknown';
63
64
  case 'codex':
64
65
  return 'codex://app-server (local process)';
65
66
  default:
@@ -366,96 +367,134 @@ function normaliseToolIdentifier(name = "") {
366
367
  }
367
368
 
368
369
 
369
- /**
370
- * Count tool_use and tool_result blocks in message history.
371
- * Only counts tools from the CURRENT TURN (after the last user text message).
372
- * This prevents the guard from blocking new questions after a previous loop.
373
- */
374
- function countToolCallsInHistory(messages) {
375
- if (!Array.isArray(messages)) return { toolUseCount: 0, toolResultCount: 0 };
376
-
377
- // Find the index of the last user message that contains actual text (not just tool_result)
378
- let lastUserTextIndex = -1;
379
- for (let i = messages.length - 1; i >= 0; i--) {
380
- const msg = messages[i];
381
- if (msg?.role !== 'user') continue;
370
+ // (countToolCallsInHistory was removed along with the dedup force-terminate
371
+ // path — the dedup counter is observe-only and no longer needs to slice the
372
+ // current turn's tool results to synthesize a forced response.)
382
373
 
383
- // Check if this user message has actual text content (not just tool_result)
384
- if (typeof msg.content === 'string' && msg.content.trim().length > 0) {
385
- lastUserTextIndex = i;
386
- break;
387
- }
388
- if (Array.isArray(msg.content)) {
389
- const hasText = msg.content.some(block =>
390
- (block?.type === 'text' && block?.text?.trim?.().length > 0) ||
391
- (block?.type === 'input_text' && block?.input_text?.trim?.().length > 0)
392
- );
393
- if (hasText) {
394
- lastUserTextIndex = i;
395
- break;
396
- }
397
- }
398
- }
399
-
400
- // Count only tool_use/tool_result AFTER the last user text message
401
- let toolUseCount = 0;
402
- let toolResultCount = 0;
403
-
404
- const startIndex = lastUserTextIndex >= 0 ? lastUserTextIndex : 0;
374
+ // === CROSS-REQUEST TOOL CALL DEDUP TRACKING ===
375
+ // These helpers track tool call signatures across multiple HTTP requests within
376
+ // the same session (client/passthrough mode). The inner-loop detection in
377
+ // runAgentLoop() only sees one request at a time, so repeated calls across
378
+ // requests escape it.
405
379
 
406
- for (let i = startIndex; i < messages.length; i++) {
407
- const msg = messages[i];
408
- if (!msg || !Array.isArray(msg.content)) continue;
380
+ const DEDUP_MAX_SIGNATURES = 50;
381
+ // Observe-only threshold: how many similar calls before the counter emits
382
+ // its once-per-question log line + metrics signal. This is diagnostics, not
383
+ // enforcement — no message is ever injected and no turn is ever terminated
384
+ // on this signal (see the observe-only note at the consumer). Read at call
385
+ // time (not module load) so it's env-tunable per deployment and testable.
386
+ const DEDUP_OBSERVE_THRESHOLD_DEFAULT = 8;
387
+
388
+ function dedupWarnThreshold() {
389
+ const n = Number.parseInt(process.env.LYNKR_DEDUP_WARN_THRESHOLD, 10);
390
+ return Number.isNaN(n) || n < 2 ? DEDUP_OBSERVE_THRESHOLD_DEFAULT : n;
391
+ }
409
392
 
410
- for (const block of msg.content) {
411
- if (block?.type === 'tool_use') toolUseCount++;
412
- if (block?.type === 'tool_result') toolResultCount++;
413
- }
414
- }
393
+ // Cached minimum context window across the configured TIER_* models — the
394
+ // conservative floor for requests whose actual model can't be resolved
395
+ // (virtual names like "lynkr-auto" before a session has a pin). 60s TTL so
396
+ // tier-config hot-reloads are picked up.
397
+ let _minTierWindowCache = { at: 0, value: null };
398
+ const MIN_TIER_WINDOW_TTL_MS = 60_000;
415
399
 
416
- return { toolUseCount, toolResultCount, lastUserTextIndex };
400
+ function minConfiguredTierWindow() {
401
+ const now = Date.now();
402
+ if (now - _minTierWindowCache.at < MIN_TIER_WINDOW_TTL_MS) return _minTierWindowCache.value;
403
+ let min = null;
404
+ try {
405
+ const { getModelTierSelector } = require("../routing/model-tiers");
406
+ const { contextWindowFor } = require("../routing/model-registry");
407
+ for (const m of getModelTierSelector().getAllConfiguredModels()) {
408
+ const w = contextWindowFor(m.model);
409
+ if (w && (!min || w < min)) min = w;
410
+ }
411
+ } catch { /* tier config unavailable — fall through to the default */ }
412
+ _minTierWindowCache = { at: now, value: min };
413
+ return min;
417
414
  }
418
415
 
416
+ // Absolute fallback when nothing at all resolves (no registry hit, no pin,
417
+ // no priced tier models). 200k-class minus headroom — the historical default.
418
+ const TOKEN_BUDGET_FALLBACK = 180000;
419
+
419
420
  /**
420
- * Inject a "stop looping" instruction if there are too many tool calls in history.
421
- * This helps prevent infinite loops when the model keeps calling tools instead of responding.
421
+ * Model-aware token budget: the served model's real context window
422
+ * (registry-sourced) shrunk to a safe working budget. Single source of truth
423
+ * for both the token-budget enforcement pass AND the history compressor —
424
+ * the compressor must target the same budget the enforcer checks, or it
425
+ * over-compresses (live incident: fixed 10-message window kept ~56k tokens
426
+ * while 108.8k were available).
422
427
  *
423
- * @param {Array} messages - The conversation messages
424
- * @param {number} threshold - Max tool results before injection (default: 5)
425
- * @returns {Array} - Messages with stop instruction injected if needed
428
+ * Resolution order — most specific knowledge of the ACTUAL model wins:
429
+ * 1. the session pin's model — ground truth for what is really serving.
430
+ * Requested names lie in both virtual cases: "lynkr-auto" resolves in
431
+ * the registry (a literal 128k entry — the incident's mystery number),
432
+ * and tier-slot names (Claude Desktop's "claude-sonnet-5" = COMPLEX
433
+ * tier) resolve to the SLOT model's window, not the tier's configured
434
+ * model actually serving. The pin knows the truth; it wins.
435
+ * 2. requested model known to the registry (clients sending real names;
436
+ * "lynkr-*" virtual names are excluded — they must never resolve)
437
+ * 3. minimum window across configured TIER_* models (conservative floor
438
+ * for unpinned virtual-name requests: safe for whatever routing picks)
439
+ * 4. TOKEN_BUDGET_FALLBACK
440
+ *
441
+ * TOKEN_BUDGET_MAX is an OPT-IN cost/latency ceiling, applied only when the
442
+ * env var is explicitly set. It is deliberately no longer a default clamp:
443
+ * overflow safety is fully handled by the per-model ×0.85 math, and cost
444
+ * control belongs to the honest protocol layers (budgets, TPM) — not to a
445
+ * silent global squeeze that turns a 1M-context model into a 180k one.
446
+ * Read at call time so it's testable and live-tunable.
447
+ *
448
+ * @param {string} requestedModel
449
+ * @param {string|null} [sessionId] - for pin-aware resolution (step 2)
450
+ * @returns {{ modelContextWindow: number, effectiveMax: number, effectiveWarning: number, source: string }}
426
451
  */
427
- function injectToolLoopStopInstruction(messages, threshold = 5) {
428
- if (!Array.isArray(messages)) return messages;
452
+ function computeModelTokenBudget(requestedModel, sessionId = null) {
453
+ const { contextWindowFor } = require("../routing/model-registry");
429
454
 
430
- const { toolResultCount } = countToolCallsInHistory(messages);
455
+ let modelContextWindow = null;
456
+ let source = null;
431
457
 
432
- if (toolResultCount >= threshold) {
433
- logger.warn({
434
- toolResultCount,
435
- threshold,
436
- }, "[ToolLoopGuard] Too many tool results in conversation - injecting stop instruction");
437
-
438
- // Inject instruction to stop tool calls and provide a final answer
439
- const stopInstruction = {
440
- role: "user",
441
- content: `⚠️ IMPORTANT: You have already executed ${toolResultCount} tool calls in this conversation. This is likely an infinite loop. STOP calling tools immediately and provide a direct text response to the user based on the information you have gathered. If you cannot complete the task, explain why. DO NOT call any more tools. Your response must contain your actual findings — NOT an acknowledgment or restatement of this warning.`,
442
- };
458
+ // 1. Session pin — the actually-serving model, when known.
459
+ if (sessionId) {
460
+ try {
461
+ const pin = sessionAffinity.getPin(sessionId);
462
+ if (pin?.model) {
463
+ modelContextWindow = contextWindowFor(pin.model);
464
+ if (modelContextWindow) source = 'session-pin';
465
+ }
466
+ } catch { /* pin store unavailable — fall through */ }
467
+ }
443
468
 
444
- return [...messages, stopInstruction];
469
+ // 2. Requested model — only for real names. Lynkr's own virtual names
470
+ // must never resolve here (a "lynkr-auto" registry entry exists and
471
+ // longest-prefix matching extends it to any lynkr-* string).
472
+ const isVirtualName = /^lynkr[-_]/i.test(requestedModel || '');
473
+ if (!modelContextWindow && !isVirtualName) {
474
+ modelContextWindow = contextWindowFor(requestedModel);
475
+ if (modelContextWindow) source = 'requested-model';
445
476
  }
446
477
 
447
- return messages;
448
- }
478
+ if (!modelContextWindow) {
479
+ modelContextWindow = minConfiguredTierWindow();
480
+ if (modelContextWindow) source = 'min-tier';
481
+ }
449
482
 
450
- // === CROSS-REQUEST TOOL CALL DEDUP TRACKING ===
451
- // These helpers track tool call signatures across multiple HTTP requests within
452
- // the same session (client/passthrough mode). The inner-loop detection in
453
- // runAgentLoop() only sees one request at a time, so repeated calls across
454
- // requests escape it.
483
+ if (!modelContextWindow) {
484
+ modelContextWindow = TOKEN_BUDGET_FALLBACK;
485
+ source = 'default';
486
+ }
455
487
 
456
- const DEDUP_MAX_SIGNATURES = 50;
457
- const DEDUP_WARN_THRESHOLD = 5;
458
- const DEDUP_TERMINATE_THRESHOLD = 8;
488
+ const modelMax = Math.floor(modelContextWindow * 0.85);
489
+ const explicitCap = process.env.TOKEN_BUDGET_MAX
490
+ ? Number.parseInt(process.env.TOKEN_BUDGET_MAX, 10)
491
+ : null;
492
+ const effectiveMax = explicitCap && explicitCap > 0
493
+ ? Math.min(modelMax, explicitCap)
494
+ : modelMax;
495
+ const effectiveWarning = Math.floor(effectiveMax * 0.65);
496
+ return { modelContextWindow, effectiveMax, effectiveWarning, source };
497
+ }
459
498
 
460
499
  /**
461
500
  * State-management tools are called repeatedly with similar args BY DESIGN —
@@ -718,52 +757,6 @@ function normaliseToolChoice(choice) {
718
757
  return undefined;
719
758
  }
720
759
 
721
- /**
722
- * Strip thinking-style reasoning from Ollama model outputs
723
- * Patterns to remove:
724
- * - Lines starting with bullet points (●, •, -, *)
725
- * - Explanatory reasoning before the actual response
726
- * - Multiple newlines used to separate thinking from response
727
- */
728
- function stripThinkingBlocks(text) {
729
- if (typeof text !== "string") return text;
730
-
731
- const lines = text.split("\n");
732
- const cleanedLines = [];
733
- let inThinkingBlock = false;
734
- let consecutiveEmptyLines = 0;
735
-
736
- for (const line of lines) {
737
- const trimmed = line.trim();
738
-
739
- // Detect thinking block markers (bullet points followed by reasoning)
740
- if (/^[●•\-\*]\s/.test(trimmed)) {
741
- inThinkingBlock = true;
742
- continue;
743
- }
744
-
745
- // Empty lines might separate thinking from response
746
- if (trimmed === "") {
747
- consecutiveEmptyLines++;
748
- // If we've seen 2+ empty lines, likely end of thinking block
749
- if (consecutiveEmptyLines >= 2) {
750
- inThinkingBlock = false;
751
- }
752
- continue;
753
- }
754
-
755
- consecutiveEmptyLines = 0;
756
-
757
- if (inThinkingBlock) {
758
- continue;
759
- }
760
-
761
- cleanedLines.push(line);
762
- }
763
-
764
- return cleanedLines.join("\n").trim();
765
- }
766
-
767
760
  /**
768
761
  * Convert legacy Ollama /api/chat response to Anthropic Messages format.
769
762
  * Used when Ollama < v0.14.0 (no native Anthropic endpoint).
@@ -1157,6 +1150,14 @@ function sanitizePayload(payload) {
1157
1150
  } else {
1158
1151
  clean.tools = ensureAnthropicToolFormat(clean.tools);
1159
1152
  }
1153
+ } else if (providerType === "fireworks") {
1154
+ // Fireworks supports OpenAI-style tools - keep them in Anthropic format
1155
+ // They will be converted to OpenAI format in invokeFireworks
1156
+ if (!Array.isArray(clean.tools) || clean.tools.length === 0) {
1157
+ delete clean.tools;
1158
+ } else {
1159
+ clean.tools = ensureAnthropicToolFormat(clean.tools);
1160
+ }
1160
1161
  } else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") {
1161
1162
  // Azure OpenAI / OpenAI-compatible providers support tools — keep Anthropic format; the
1162
1163
  // client converts to Chat Completions / Responses format. Without this
@@ -1392,7 +1393,6 @@ async function runAgentLoop({
1392
1393
  requestedModel,
1393
1394
  wantsThinking,
1394
1395
  session,
1395
- cwd,
1396
1396
  options,
1397
1397
  cacheKey,
1398
1398
  providerType,
@@ -1513,7 +1513,11 @@ async function runAgentLoop({
1513
1513
  cleanPayload.messages = historyCompression.compressHistory(originalMessages, {
1514
1514
  keepRecentTurns: config.historyCompression?.keepRecentTurns ?? 10,
1515
1515
  summarizeOlder: config.historyCompression?.summarizeOlder ?? true,
1516
- enabled: true
1516
+ enabled: true,
1517
+ // Budget-driven recent window: keep as many verbatim recent
1518
+ // messages as the model's context budget allows, instead of a
1519
+ // fixed 10 (see compressHistory for the incident this fixes).
1520
+ budgetTokens: computeModelTokenBudget(requestedModel, session?.id ?? null).effectiveMax,
1517
1521
  });
1518
1522
 
1519
1523
  if (cleanPayload.messages !== originalMessages) {
@@ -1522,6 +1526,24 @@ async function runAgentLoop({
1522
1526
  sessionId: session?.id ?? null,
1523
1527
  ...stats
1524
1528
  }, 'History compression applied');
1529
+
1530
+ // Degradation visibility: severe compression means the model is
1531
+ // seeing a small fraction of the session. Count it, and warn
1532
+ // (once per session) past 50% — operators should not need log
1533
+ // forensics to learn their agent has amnesia.
1534
+ const pct = Number.parseFloat(stats.percentage) || 0;
1535
+ try { getMetricsCollector().recordHistoryCompression(pct); } catch { /* best-effort */ }
1536
+ if (pct > 50 && session?.metadata && !session.metadata._compressionWarned) {
1537
+ session.metadata._compressionWarned = true;
1538
+ logger.warn({
1539
+ sessionId: session?.id ?? null,
1540
+ percentage: stats.percentage,
1541
+ originalMessages: stats.originalMessages,
1542
+ compressedMessages: stats.compressedMessages,
1543
+ tokensOriginal: stats.tokensOriginal,
1544
+ tokensCompressed: stats.tokensCompressed,
1545
+ }, '[HistoryCompression] Severe compression — the model sees a small fraction of this session. Consider client-side compaction or a larger-context model.');
1546
+ }
1525
1547
  }
1526
1548
  }
1527
1549
  } catch (err) {
@@ -1677,13 +1699,11 @@ IMPORTANT TOOL USAGE RULES:
1677
1699
  cleanPayload.system = (cleanPayload.system || '') + '\n\nCRITICAL: You have NO tools available. Do NOT generate tool_use, function_call, or code_execution blocks. Output ONLY text content directly.';
1678
1700
  }
1679
1701
 
1680
- // Compute model-aware token budget thresholds
1681
- const registry = getModelRegistrySync();
1682
- const modelInfo = registry.getCost(requestedModel);
1683
- const modelContextWindow = modelInfo?.context || config.tokenBudget?.max || 180000;
1684
- const modelMax = Math.floor(modelContextWindow * 0.85);
1685
- const effectiveMax = Math.min(modelMax, config.tokenBudget?.max || 180000);
1686
- const effectiveWarning = Math.floor(effectiveMax * 0.65);
1702
+ // Compute model-aware token budget thresholds (shared helper — also
1703
+ // consumed by the history-compression block above, which must target the
1704
+ // same budget it enforces).
1705
+ const { modelContextWindow, effectiveMax, effectiveWarning, source: budgetSource } =
1706
+ computeModelTokenBudget(requestedModel, session?.id ?? null);
1687
1707
 
1688
1708
  logger.debug({
1689
1709
  sessionId: session?.id ?? null,
@@ -1691,7 +1711,7 @@ IMPORTANT TOOL USAGE RULES:
1691
1711
  modelContextWindow,
1692
1712
  effectiveWarning,
1693
1713
  effectiveMax,
1694
- source: modelInfo?.source || 'default',
1714
+ source: budgetSource,
1695
1715
  }, 'Model-aware token budget computed');
1696
1716
 
1697
1717
  if (steps === 1 && config.tokenBudget?.enforcement !== false) {
@@ -2310,6 +2330,33 @@ IMPORTANT TOOL USAGE RULES:
2310
2330
  }
2311
2331
 
2312
2332
  if (toolCalls.length > 0) {
2333
+ // Auto-resolve web_search/web_fetch server-side, but ONLY for clients
2334
+ // src/routing/client-profiles.js's detectClient() didn't recognize —
2335
+ // i.e. we have no signal the caller can fulfill these itself. Known
2336
+ // harnesses (Claude Code AND Claude Desktop — both present as
2337
+ // claude-cli/... since Desktop's gateway mode runs the same agent-sdk;
2338
+ // also Cursor, goose, Codex) already execute these client-side and
2339
+ // must keep doing so unchanged — this branch never fires for them.
2340
+ // Does NOT reintroduce general server-mode tool execution (removed
2341
+ // 2026-07-22, commit b32e988): only these two tool names, only for
2342
+ // unrecognized clients, and only when EVERY call in this batch is one
2343
+ // we can resolve (a mixed batch falls through to the normal
2344
+ // forward-to-client path below, untouched).
2345
+ const clientProfile = cleanPayload._clientProfile || null;
2346
+ if (!clientProfile) {
2347
+ const webSearchExec = require("../tools/web-search-exec");
2348
+ if (webSearchExec.canAutoResolveAll(toolCalls)) {
2349
+ logger.info({
2350
+ sessionId: session?.id ?? null,
2351
+ step: steps,
2352
+ tools: toolCalls.map((tc) => tc.function?.name ?? tc.name),
2353
+ }, "[web-search-exec] Auto-resolving web_search/web_fetch for unrecognized client");
2354
+ await webSearchExec.autoResolve(toolCalls, cleanPayload.messages);
2355
+ steps++;
2356
+ continue;
2357
+ }
2358
+ }
2359
+
2313
2360
  // Convert OpenAI/OpenRouter format to Anthropic format for session storage
2314
2361
  let sessionContent;
2315
2362
  if (providerType === "azure-anthropic") {
@@ -2668,6 +2715,12 @@ IMPORTANT TOOL USAGE RULES:
2668
2715
  if (Array.isArray(anthropicPayload?.content)) {
2669
2716
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2670
2717
  }
2718
+ } else if (actualProvider === "fireworks") {
2719
+ // Fireworks responses are already converted to Anthropic format in invokeFireworks
2720
+ anthropicPayload = databricksResponse.json;
2721
+ if (Array.isArray(anthropicPayload?.content)) {
2722
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2723
+ }
2671
2724
  } else if (actualProvider === "codex") {
2672
2725
  // Codex responses are already in Anthropic format from invokeCodex
2673
2726
  anthropicPayload = databricksResponse.json;
@@ -2945,88 +2998,36 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
2945
2998
 
2946
2999
  const { maxCount, toolName: dedupToolName, signature: dedupSig } = getMaxDedupCount(session);
2947
3000
 
2948
- if (maxCount >= DEDUP_TERMINATE_THRESHOLD) {
2949
- // Force-terminate: same pattern as existing tool_loop_guard
2950
- logger.error({
2951
- toolName: dedupToolName,
2952
- count: maxCount,
2953
- threshold: DEDUP_TERMINATE_THRESHOLD,
2954
- signature: dedupSig,
2955
- sessionId: session?.id ?? null,
2956
- }, "[CrossRequestDedup] FORCE TERMINATING - repeated tool call across requests");
2957
-
2958
- // Extract tool results summary from current turn
2959
- let toolResultsSummary = "";
2960
- const messages = payload?.messages || [];
2961
- const { lastUserTextIndex: luIdx } = countToolCallsInHistory(messages);
2962
- const startIdx = luIdx >= 0 ? luIdx : 0;
2963
- for (let i = startIdx; i < messages.length; i++) {
2964
- const msg = messages[i];
2965
- if (!msg || !Array.isArray(msg.content)) continue;
2966
- for (const block of msg.content) {
2967
- if (block?.type === 'tool_result' && block?.content) {
2968
- const content = typeof block.content === 'string'
2969
- ? block.content
2970
- : JSON.stringify(block.content);
2971
- if (content && !content.includes('Found 0')) {
2972
- toolResultsSummary += content + "\n";
2973
- }
2974
- }
2975
- }
2976
- }
2977
-
2978
- let responseText = `Based on the tool results, here's what I found:\n\n`;
2979
- if (toolResultsSummary.trim()) {
2980
- responseText += toolResultsSummary.trim();
2981
- } else {
2982
- responseText += `The tools executed but didn't return clear results. Please check the tool output above or try a different command.`;
2983
- }
2984
-
2985
- const forcedResponse = {
2986
- id: `msg_forced_${Date.now()}`,
2987
- type: "message",
2988
- role: "assistant",
2989
- content: [{ type: "text", text: responseText }],
2990
- model: requestedModel || "unknown",
2991
- stop_reason: "end_turn",
2992
- stop_sequence: null,
2993
- usage: { input_tokens: 0, output_tokens: 100 },
2994
- };
2995
-
2996
- // Reset dedup after termination so next question starts fresh
2997
- resetDedupTracking(session);
2998
- // Persist to DB (non-ephemeral sessions only)
2999
- if (session.id && !session._ephemeral) {
3000
- try { upsertSession(session.id, { metadata: session.metadata }); } catch (e) {
3001
- logger.debug({ err: e.message }, "Failed to persist dedup reset");
3002
- }
3003
- }
3004
-
3005
- return {
3006
- status: 200,
3007
- body: forcedResponse,
3008
- terminationReason: "tool_loop_guard",
3009
- };
3010
- }
3011
-
3012
- if (maxCount >= DEDUP_WARN_THRESHOLD && !dedup.warningInjected) {
3001
+ // OBSERVE-ONLY (deliberate design decision, 2026-09): the proxy meters
3002
+ // and reports — it never judges behavior into the conversation. The
3003
+ // previous enforcement here (an injected "loop guard" message at the
3004
+ // warn threshold, a forced synthetic response at the terminate
3005
+ // threshold) was removed after a live incident where it repeatedly
3006
+ // killed legitimate work: the proxy has the weakest evidence of any
3007
+ // layer (arg previews + similarity heuristics), so it carries an
3008
+ // irreducible false-positive floor, and a false positive here
3009
+ // sabotages the user's task invisibly. Loop *breaking* belongs to the
3010
+ // harness and the model; Lynkr's protection against runaway spend is
3011
+ // the honest, protocol-level layer: TPM limits (LYNKR_TPM_LIMIT),
3012
+ // budgets, and the loop-guard middleware's turn caps
3013
+ // (LYNKR_MAX_SESSION_TURNS / LYNKR_MAX_TOOL_TURNS) — all visible
3014
+ // 429/402 refusals, never ghostwritten messages.
3015
+ //
3016
+ // The counter itself stays: it costs nothing, mutates nothing, and is
3017
+ // exactly the signal an operator needs to diagnose loop-shaped waste
3018
+ // (log line + metrics; dashboards/statusline read the metrics).
3019
+ if (maxCount >= dedupWarnThreshold() && !dedup.warningInjected) {
3013
3020
  logger.warn({
3014
3021
  toolName: dedupToolName,
3015
3022
  count: maxCount,
3016
- threshold: DEDUP_WARN_THRESHOLD,
3023
+ threshold: dedupWarnThreshold(),
3017
3024
  signature: dedupSig,
3018
3025
  sessionId: session?.id ?? null,
3019
- }, "[CrossRequestDedup] Warning - repeated tool call detected across requests");
3020
-
3026
+ }, "[CrossRequestDedup] Repeated similar tool calls observed (observe-only — no intervention)");
3027
+ try { getMetricsCollector().recordLoopGuard('observed'); } catch { /* metrics best-effort */ }
3028
+ // Field name kept for persisted-session compatibility; semantics are
3029
+ // now "signal emitted once per question", not "warning injected".
3021
3030
  dedup.warningInjected = true;
3022
-
3023
- // Inject a strict warning into the payload so the model sees it
3024
- if (Array.isArray(payload?.messages)) {
3025
- payload.messages.push({
3026
- role: "user",
3027
- content: `⚠️ CRITICAL SYSTEM WARNING: You have called the "${dedupToolName}" tool ${maxCount} times with identical or similar parameters across multiple requests. This IS an infinite loop. STOP calling this tool immediately. You MUST now provide a direct text response based on the results you have received. If the tool returned "no results" or empty output, that IS the final answer - do not retry. Your response must contain your actual findings from the tool results gathered so far — NOT an acknowledgment or restatement of this warning. Do not mention this warning in your response.`,
3028
- });
3029
- }
3030
3031
  }
3031
3032
 
3032
3033
  // Persist dedup state (non-ephemeral sessions only)
@@ -3037,9 +3038,10 @@ async function processMessage({ payload, headers, session, cwd, options = {} })
3037
3038
  }
3038
3039
  }
3039
3040
 
3040
- // No count-based tool_loop_guard. Natural limits (maxSteps, maxDurationMs,
3041
- // provider token/rate limits, client-side loop detection, and the
3042
- // cross-request dedup above) are sufficient protection.
3041
+ // No count-based tool_loop_guard and no dedup-based enforcement (see
3042
+ // observe-only note above). Hard limits live at the protocol layer:
3043
+ // maxSteps, maxDurationMs, provider token/rate limits, TPM, budgets,
3044
+ // and the loop-guard middleware's turn caps.
3043
3045
  }
3044
3046
 
3045
3047
  const { createTimer } = require("../utils/perf-timer");
@@ -3232,4 +3234,6 @@ module.exports = {
3232
3234
  trimLoopMessages,
3233
3235
  // Exported for unit testing of the live-stream routing badge.
3234
3236
  buildRoutingBadge,
3237
+ // Exported for unit testing of pin-aware model budget resolution.
3238
+ computeModelTokenBudget,
3235
3239
  };
@@ -261,7 +261,13 @@ async function handleNativeStream(req, res, opts = {}) {
261
261
  });
262
262
  res.set("X-Lynkr-Provider", tier.provider || "azure-anthropic");
263
263
  if (tier.tier) res.set("X-Lynkr-Tier", tier.tier);
264
- if (tier.model || req.body?.model) res.set("X-Lynkr-Model", tier.model || req.body.model);
264
+ if (tier.model || req.body?.model) {
265
+ const servedModel = tier.model || req.body.model;
266
+ res.set("X-Lynkr-Model", servedModel);
267
+ const { contextWindowFor } = require("../routing/model-registry");
268
+ const contextWindow = contextWindowFor(servedModel);
269
+ if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
270
+ }
265
271
  res.set("X-Lynkr-Routing-Method", "native-passthrough-stream");
266
272
  if (typeof res.flushHeaders === "function") res.flushHeaders();
267
273