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.
- 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 +16 -22
- 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/health.js +0 -1
- 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/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- 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/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- 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/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- 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/regret-estimator.js +1 -1
- 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/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- 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
|
|
20
|
+
const { getSemanticCache } = require("../cache/semantic");
|
|
22
21
|
const { areSimilarToolCalls } = require("../clients/gpt-utils");
|
|
23
|
-
const {
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
421
|
-
*
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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
|
|
428
|
-
|
|
452
|
+
function computeModelTokenBudget(requestedModel, sessionId = null) {
|
|
453
|
+
const { contextWindowFor } = require("../routing/model-registry");
|
|
429
454
|
|
|
430
|
-
|
|
455
|
+
let modelContextWindow = null;
|
|
456
|
+
let source = null;
|
|
431
457
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
478
|
+
if (!modelContextWindow) {
|
|
479
|
+
modelContextWindow = minConfiguredTierWindow();
|
|
480
|
+
if (modelContextWindow) source = 'min-tier';
|
|
481
|
+
}
|
|
449
482
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// requests escape it.
|
|
483
|
+
if (!modelContextWindow) {
|
|
484
|
+
modelContextWindow = TOKEN_BUDGET_FALLBACK;
|
|
485
|
+
source = 'default';
|
|
486
|
+
}
|
|
455
487
|
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
|
|
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
|
-
|
|
1682
|
-
|
|
1683
|
-
const modelContextWindow
|
|
1684
|
-
|
|
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:
|
|
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
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
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:
|
|
3023
|
+
threshold: dedupWarnThreshold(),
|
|
3017
3024
|
signature: dedupSig,
|
|
3018
3025
|
sessionId: session?.id ?? null,
|
|
3019
|
-
}, "[CrossRequestDedup]
|
|
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
|
|
3041
|
-
//
|
|
3042
|
-
//
|
|
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)
|
|
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
|
|