lynkr 9.14.20 → 9.14.22
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lynkr",
|
|
3
|
-
"version": "9.14.
|
|
3
|
+
"version": "9.14.22",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"install.sh",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"start:supervised": "while true; do node index.js 2>&1 | npx pino-pretty --sync; code=$?; echo \"[supervisor] lynkr exited ($code) — restarting in 3s\"; sleep 3; done",
|
|
39
39
|
"lint": "eslint src index.js",
|
|
40
40
|
"test": "npm run test:unit && npm run test:performance",
|
|
41
|
-
"test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/harness-envelope.test.js test/task-ledger.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js test/shortfall-semantic.test.js test/routing-decisions.test.js test/routing-corpus.test.js test/switch-gate-grounding.test.js",
|
|
41
|
+
"test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/openai-ingress-tool-injection.test.js test/tool-result-compression-gate.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/harness-envelope.test.js test/task-ledger.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js test/shortfall-semantic.test.js test/routing-decisions.test.js test/routing-corpus.test.js test/switch-gate-grounding.test.js",
|
|
42
42
|
"test:memory": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
|
|
43
43
|
"test:new-features": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
|
|
44
44
|
"test:performance": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
|
package/src/api/openai-router.js
CHANGED
|
@@ -28,6 +28,23 @@ const clientProfiles = require("../routing/client-profiles");
|
|
|
28
28
|
|
|
29
29
|
const router = express.Router();
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* WS3 — client-profile detection for the OpenAI-compat ingress. Wraps
|
|
33
|
+
* src/routing/client-profiles.js so a detector bug can never fail a request.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} headers - incoming request headers
|
|
36
|
+
* @param {object} payload - the Anthropic-shaped request (tools as sent, or as injected)
|
|
37
|
+
* @returns {object|null} matched profile or null
|
|
38
|
+
*/
|
|
39
|
+
function detectClientProfile(headers, payload) {
|
|
40
|
+
try {
|
|
41
|
+
return clientProfiles.detectClient({ headers, payload }) || null;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
/**
|
|
32
49
|
* Served-model decision headers for the OpenAI-compat ingress.
|
|
33
50
|
*
|
|
@@ -551,48 +568,66 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
551
568
|
|
|
552
569
|
const anthropicRequest = convertOpenAIToAnthropic(req.body);
|
|
553
570
|
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
571
|
+
// WS3 — detect the client harness BEFORE tool injection, on the request
|
|
572
|
+
// as the client sent it (user-agent / x-client header, prompt marker, or
|
|
573
|
+
// the tools it actually attached). A profile found here may veto
|
|
574
|
+
// injection (`injectTools: false`): app harnesses such as OpenMausBot
|
|
575
|
+
// execute only the tools they sent, so an injected IDE_SAFE_TOOLS
|
|
576
|
+
// tool_call would come back to them as a failed turn. For every other
|
|
577
|
+
// client this pass matches exactly what the post-injection pass below
|
|
578
|
+
// used to — a harness that identifies itself or fingerprints on its own
|
|
579
|
+
// loadout matches the same profile either way.
|
|
580
|
+
let clientProfile = detectClientProfile(req.headers, anthropicRequest);
|
|
581
|
+
|
|
582
|
+
// Tool injection — ONLY for the coding harnesses Lynkr has a tool mapping
|
|
583
|
+
// for (CLIENT_TOOL_MAPPINGS: codex, cline, kilo, continue). They expect
|
|
584
|
+
// Lynkr's IDE_SAFE_TOOLS under their own names, filtered to the tools
|
|
585
|
+
// they can execute (no Task / WebFetch / NotebookEdit for Codex).
|
|
586
|
+
//
|
|
587
|
+
// Everyone else gets their request upstream exactly as sent. Until
|
|
588
|
+
// 2026-10 an unrecognized tool-less request received the full
|
|
589
|
+
// IDE_SAFE_TOOLS set; that turned every tool-less chat — Cursor's chat
|
|
590
|
+
// mode, goose's summarizer side-requests, Terminus, OpenMausBot bots,
|
|
591
|
+
// plain curl — into a tool-calling turn the caller could not execute,
|
|
592
|
+
// the same failure #120 removed on the provider side. A client profile
|
|
593
|
+
// with `injectTools: false` additionally vetoes injection outright.
|
|
594
|
+
//
|
|
595
|
+
// An explicit opt-out (tool_choice: "none" or any tools array, even
|
|
596
|
+
// empty) is honoured for every client, mapped or not.
|
|
562
597
|
const clientType = detectClient(req.headers);
|
|
598
|
+
const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
|
|
563
599
|
const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
:
|
|
600
|
+
const requestHasNoTools = !anthropicRequest.tools || anthropicRequest.tools.length === 0;
|
|
601
|
+
if (clientProfiles.vetoesToolInjection(clientProfile)) {
|
|
602
|
+
logger.debug({
|
|
603
|
+
clientType,
|
|
604
|
+
clientProfile: clientProfile?.name ?? null,
|
|
605
|
+
reqToolsPresent: Array.isArray(req.body.tools),
|
|
606
|
+
}, "=== TOOLS NOT INJECTED — client profile executes only the tools it sent ===");
|
|
607
|
+
} else if (requestHasNoTools && !clientExplicitlyDisabledTools && !clientMappings) {
|
|
608
|
+
logger.debug({
|
|
609
|
+
clientType,
|
|
610
|
+
clientProfile: clientProfile?.name ?? null,
|
|
611
|
+
}, "=== TOOLS NOT INJECTED — no tool mapping for this client; request forwarded tool-less ===");
|
|
612
|
+
} else if (requestHasNoTools && !clientExplicitlyDisabledTools) {
|
|
613
|
+
const clientTools = IDE_SAFE_TOOLS.filter(t => clientMappings[t.name]);
|
|
569
614
|
anthropicRequest.tools = clientTools;
|
|
570
615
|
logger.debug({
|
|
571
616
|
clientType,
|
|
572
617
|
injectedToolCount: clientTools.length,
|
|
573
618
|
injectedToolNames: clientTools.map(t => t.name),
|
|
574
|
-
reason:
|
|
575
|
-
? `Known client '${clientType}' — filtered to mapped tools only`
|
|
576
|
-
: "Unknown client — injecting full IDE_SAFE_TOOLS"
|
|
619
|
+
reason: `Known client '${clientType}' — filtered to mapped tools only`,
|
|
577
620
|
}, "=== INJECTING TOOLS ===");
|
|
578
621
|
}
|
|
579
622
|
|
|
580
|
-
// WS3 —
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
// including "hi" — at the ITERATIVE minTier (COMPLEX).
|
|
587
|
-
try {
|
|
588
|
-
const profile = clientProfiles.detectClient({
|
|
589
|
-
headers: req.headers,
|
|
590
|
-
payload: anthropicRequest,
|
|
591
|
-
});
|
|
592
|
-
if (profile) anthropicRequest._clientProfile = profile;
|
|
593
|
-
} catch (err) {
|
|
594
|
-
logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
|
|
623
|
+
// WS3 — second detection pass, only when the first found nothing and
|
|
624
|
+
// tools were just injected: the mapped loadout may now fingerprint a
|
|
625
|
+
// profile (see lynkr-injected in client-profiles.js) so the agentic
|
|
626
|
+
// detector does not count Lynkr's own tools as user intent.
|
|
627
|
+
if (!clientProfile && anthropicRequest.tools?.length) {
|
|
628
|
+
clientProfile = detectClientProfile(req.headers, anthropicRequest);
|
|
595
629
|
}
|
|
630
|
+
if (clientProfile) anthropicRequest._clientProfile = clientProfile;
|
|
596
631
|
|
|
597
632
|
// Explicit model/effort pin: Codex's model picker (or any OpenAI-format
|
|
598
633
|
// caller) can name a real OpenAI model + reasoning.effort combo that
|
|
@@ -1861,46 +1896,47 @@ router.post("/responses", async (req, res) => {
|
|
|
1861
1896
|
}))
|
|
1862
1897
|
}, "After Chat→Anthropic conversion");
|
|
1863
1898
|
|
|
1864
|
-
//
|
|
1899
|
+
// Tool injection — ONLY for clients with a CLIENT_TOOL_MAPPINGS entry
|
|
1900
|
+
// (Codex is the one that actually uses this route). See the matching
|
|
1901
|
+
// block in /v1/chat/completions for the full rationale; unrecognized
|
|
1902
|
+
// tool-less callers are forwarded tool-less, and an `injectTools: false`
|
|
1903
|
+
// client profile vetoes injection outright.
|
|
1865
1904
|
// The client may have sent tools in Responses API format (top-level name)
|
|
1866
1905
|
// which convertOpenAIToAnthropic silently drops because it expects Chat
|
|
1867
1906
|
// Completions format ({function: {name}}). Always check the CONVERTED
|
|
1868
1907
|
// result, not the raw request.
|
|
1908
|
+
let clientProfile = detectClientProfile(req.headers, anthropicRequest);
|
|
1869
1909
|
const clientDisabledToolChoice = req.body.tool_choice === "none";
|
|
1870
|
-
|
|
1910
|
+
const requestHasNoTools = !anthropicRequest.tools || anthropicRequest.tools.length === 0;
|
|
1911
|
+
if (clientProfiles.vetoesToolInjection(clientProfile)) {
|
|
1912
|
+
logger.info({
|
|
1913
|
+
clientType,
|
|
1914
|
+
clientProfile: clientProfile?.name ?? null,
|
|
1915
|
+
toolCount: anthropicRequest.tools?.length || 0,
|
|
1916
|
+
}, "=== TOOLS NOT INJECTED (responses) — client profile executes only the tools it sent ===");
|
|
1917
|
+
} else if (requestHasNoTools && !clientDisabledToolChoice && !clientMappings) {
|
|
1918
|
+
logger.info({
|
|
1919
|
+
clientType,
|
|
1920
|
+
clientProfile: clientProfile?.name ?? null,
|
|
1921
|
+
}, "=== TOOLS NOT INJECTED (responses) — no tool mapping for this client; request forwarded tool-less ===");
|
|
1922
|
+
} else if (requestHasNoTools && !clientDisabledToolChoice) {
|
|
1871
1923
|
// Exclude server-side-only tools (Task, web_search, etc.) from the
|
|
1872
1924
|
// Responses endpoint — they can't be executed by external clients like
|
|
1873
1925
|
// Codex and would be converted to broken shell echo commands.
|
|
1874
1926
|
const RESPONSES_EXCLUDED_TOOLS = new Set(["Task", "AskUserQuestion", "TodoWrite", "WebSearch", "WebFetch", "WebAgent"]);
|
|
1875
|
-
const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
|
|
1876
|
-
let clientTools = (clientMappings
|
|
1877
|
-
? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
|
|
1878
|
-
: IDE_SAFE_TOOLS
|
|
1879
|
-
).filter(t => !RESPONSES_EXCLUDED_TOOLS.has(t.name));
|
|
1880
|
-
|
|
1881
1927
|
// Rename tools to client-expected names so the model uses the right names
|
|
1882
1928
|
// e.g., for Codex: "Read" → "read_file", "Bash" → "shell"
|
|
1883
1929
|
// The lynkrToClient map above normalizes any stale Lynkr names in history
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
if (!mapping) return t;
|
|
1888
|
-
return {
|
|
1889
|
-
...t,
|
|
1890
|
-
name: mapping.name,
|
|
1891
|
-
description: t.description || '',
|
|
1892
|
-
};
|
|
1893
|
-
});
|
|
1894
|
-
}
|
|
1930
|
+
const clientTools = IDE_SAFE_TOOLS
|
|
1931
|
+
.filter(t => clientMappings[t.name] && !RESPONSES_EXCLUDED_TOOLS.has(t.name))
|
|
1932
|
+
.map(t => ({ ...t, name: clientMappings[t.name].name, description: t.description || '' }));
|
|
1895
1933
|
|
|
1896
1934
|
anthropicRequest.tools = clientTools;
|
|
1897
1935
|
logger.info({
|
|
1898
1936
|
clientType,
|
|
1899
1937
|
injectedToolCount: clientTools.length,
|
|
1900
1938
|
injectedToolNames: clientTools.map(t => t.name),
|
|
1901
|
-
reason:
|
|
1902
|
-
? `Known client '${clientType}' — tools renamed to client conventions`
|
|
1903
|
-
: "Unknown client — injecting full IDE_SAFE_TOOLS"
|
|
1939
|
+
reason: `Known client '${clientType}' — tools renamed to client conventions`,
|
|
1904
1940
|
}, "=== INJECTING TOOLS (responses) ===");
|
|
1905
1941
|
} else {
|
|
1906
1942
|
logger.info({
|
|
@@ -1923,22 +1959,14 @@ router.post("/responses", async (req, res) => {
|
|
|
1923
1959
|
anthropicRequest.tools = anthropicRequest.tools.filter(t => !RESPONSES_EXCLUDED.has(t.name));
|
|
1924
1960
|
}
|
|
1925
1961
|
|
|
1926
|
-
// WS3 —
|
|
1927
|
-
//
|
|
1928
|
-
//
|
|
1929
|
-
//
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
// including "hi" — at the ITERATIVE minTier (COMPLEX).
|
|
1933
|
-
try {
|
|
1934
|
-
const profile = clientProfiles.detectClient({
|
|
1935
|
-
headers: req.headers,
|
|
1936
|
-
payload: anthropicRequest,
|
|
1937
|
-
});
|
|
1938
|
-
if (profile) anthropicRequest._clientProfile = profile;
|
|
1939
|
-
} catch (err) {
|
|
1940
|
-
logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
|
|
1962
|
+
// WS3 — second detection pass, only when the pre-injection pass found
|
|
1963
|
+
// nothing and tools were just injected, so the mapped loadout can
|
|
1964
|
+
// fingerprint a profile and the agentic detector does not count Lynkr's
|
|
1965
|
+
// own tools as user intent.
|
|
1966
|
+
if (!clientProfile && anthropicRequest.tools?.length) {
|
|
1967
|
+
clientProfile = detectClientProfile(req.headers, anthropicRequest);
|
|
1941
1968
|
}
|
|
1969
|
+
if (clientProfile) anthropicRequest._clientProfile = clientProfile;
|
|
1942
1970
|
|
|
1943
1971
|
// Explicit model/effort pin — see the identical block in /chat/completions
|
|
1944
1972
|
// above for the full rationale. This is the route Codex Desktop actually
|
|
@@ -3,6 +3,7 @@ const { invokeModel, toolsDeclined } = require("../clients/databricks");
|
|
|
3
3
|
const { appendTurnToSession } = require("../sessions/record");
|
|
4
4
|
const { upsertSession } = require("../sessions/store");
|
|
5
5
|
const policy = require("../policy");
|
|
6
|
+
const { vetoesToolResultCompression } = require("../routing/client-profiles");
|
|
6
7
|
const logger = require("../logger");
|
|
7
8
|
const promptCache = require("../cache/prompt");
|
|
8
9
|
const tokens = require("../utils/tokens");
|
|
@@ -1955,8 +1956,10 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1955
1956
|
}
|
|
1956
1957
|
|
|
1957
1958
|
// RTK-inspired tool result compression: compress large tool_results
|
|
1958
|
-
// before they reach the model (saves 60-90% on test/git/lint output)
|
|
1959
|
-
|
|
1959
|
+
// before they reach the model (saves 60-90% on test/git/lint output).
|
|
1960
|
+
// Skipped for clients whose profile says their tool results are data,
|
|
1961
|
+
// not re-runnable command output (see toolResultCompressionAllowed).
|
|
1962
|
+
if (toolResultCompressionAllowed(cleanPayload)) {
|
|
1960
1963
|
const { compressToolResults } = require("../context/tool-result-compressor");
|
|
1961
1964
|
// Fixed threshold: compression must be deterministic per message — the
|
|
1962
1965
|
// routed tier flaps between turns, and re-compressing history differently
|
|
@@ -3299,8 +3302,36 @@ function trimLoopMessages(messages, max) {
|
|
|
3299
3302
|
return trimmed;
|
|
3300
3303
|
}
|
|
3301
3304
|
|
|
3305
|
+
/**
|
|
3306
|
+
* Whether the tool-result compressor may run on this request's history.
|
|
3307
|
+
*
|
|
3308
|
+
* Off globally via TOOL_RESULT_COMPRESSION_ENABLED=false. Otherwise on,
|
|
3309
|
+
* unless the request's detected client profile (src/routing/client-profiles.js,
|
|
3310
|
+
* attached by the ingress routers as `payload._clientProfile`) carries
|
|
3311
|
+
* `compressToolResults: false`. The compressor was built for shell output a
|
|
3312
|
+
* coding agent can re-run; an app harness such as OpenMausBot sends tool
|
|
3313
|
+
* results that ARE the data, and may append its own paging notice (saved
|
|
3314
|
+
* result id + offset) that compressJSON would delete — the model then pages
|
|
3315
|
+
* with a bad id and the harness fails the turn (2026-10-14).
|
|
3316
|
+
*
|
|
3317
|
+
* @param {object} payload - sanitized request payload
|
|
3318
|
+
* @returns {boolean}
|
|
3319
|
+
*/
|
|
3320
|
+
function toolResultCompressionAllowed(payload) {
|
|
3321
|
+
if (config.toolResultCompression?.enabled === false) return false;
|
|
3322
|
+
if (vetoesToolResultCompression(payload?._clientProfile)) {
|
|
3323
|
+
logger.debug({
|
|
3324
|
+
clientProfile: payload._clientProfile?.name,
|
|
3325
|
+
}, "Tool result compression skipped — client profile keeps tool results intact");
|
|
3326
|
+
return false;
|
|
3327
|
+
}
|
|
3328
|
+
return true;
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3302
3331
|
module.exports = {
|
|
3303
3332
|
processMessage,
|
|
3333
|
+
// Exported for unit testing of the client-profile gate on tool-result compression.
|
|
3334
|
+
toolResultCompressionAllowed,
|
|
3304
3335
|
// Exported for unit testing of sanitizePayload tool handling.
|
|
3305
3336
|
sanitizePayload,
|
|
3306
3337
|
// Exported for unit testing of response-metadata conversion.
|
|
@@ -11,10 +11,28 @@
|
|
|
11
11
|
* specific spot for one specific client — but it also discarded real MCP
|
|
12
12
|
* tools the user had configured, and did nothing for Cursor / Codex.
|
|
13
13
|
*
|
|
14
|
-
* This module fixes the root cause. Given a request's `user-agent`
|
|
15
|
-
* and its tools list, `detectClient` returns the matching
|
|
16
|
-
* and `effectiveTools` subtracts the baseline so the
|
|
17
|
-
* only tools the user actually added beyond the
|
|
14
|
+
* This module fixes the root cause. Given a request's `user-agent` (or
|
|
15
|
+
* `x-client`) header and its tools list, `detectClient` returns the matching
|
|
16
|
+
* profile (or null), and `effectiveTools` subtracts the baseline so the
|
|
17
|
+
* agentic detector scores only tools the user actually added beyond the
|
|
18
|
+
* harness default.
|
|
19
|
+
*
|
|
20
|
+
* A profile may also carry behaviour flags the ingress routers read:
|
|
21
|
+
* - `toolless: true` — the client never sends tools; src/api/router.js
|
|
22
|
+
* uses it to tell harness side-traffic from plain
|
|
23
|
+
* API clients.
|
|
24
|
+
* - `injectTools: false` — the OpenAI-compat routers must NOT inject
|
|
25
|
+
* IDE_SAFE_TOOLS for this client. App harnesses
|
|
26
|
+
* such as OpenMausBot execute only the tools they
|
|
27
|
+
* sent; an injected tool_call comes back to them
|
|
28
|
+
* as a failed turn. See `vetoesToolInjection`.
|
|
29
|
+
* - `compressToolResults: false` — the orchestrator must NOT run the
|
|
30
|
+
* RTK-style tool-result compressor on this
|
|
31
|
+
* client's history. It was built for shell output
|
|
32
|
+
* a coding agent can re-run; an app harness's tool
|
|
33
|
+
* results are the data itself, and the harness may
|
|
34
|
+
* append its own paging notice that the compressor
|
|
35
|
+
* would delete. See `vetoesToolResultCompression`.
|
|
18
36
|
*
|
|
19
37
|
* @module routing/client-profiles
|
|
20
38
|
*/
|
|
@@ -80,14 +98,16 @@ const PROFILES = {
|
|
|
80
98
|
'lynkr-injected': {
|
|
81
99
|
name: 'lynkr-injected',
|
|
82
100
|
baselineTools: new Set([
|
|
83
|
-
// Lynkr's own IDE_SAFE_TOOLS loadout (src/clients/standard-tools.js)
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
101
|
+
// Lynkr's own IDE_SAFE_TOOLS loadout (src/clients/standard-tools.js).
|
|
102
|
+
// Until 2026-10 the OpenAI-compat routers injected it into every
|
|
103
|
+
// tool-less request from an unrecognized client; they now inject only
|
|
104
|
+
// for clients with a CLIENT_TOOL_MAPPINGS entry (codex, cline, kilo,
|
|
105
|
+
// continue), filtered to the mapped names. The profile stays so a
|
|
106
|
+
// request that does carry this loadout — a mapped client, or a caller
|
|
107
|
+
// replaying one — is still read as Lynkr plumbing, not user intent:
|
|
108
|
+
// otherwise the agentic detector floors it at ITERATIVE/COMPLEX.
|
|
109
|
+
// Matches claude-code's names plus the four (MultiEdit, LS,
|
|
110
|
+
// NotebookRead, WebAgent) that keep it from fingerprinting as claude-code.
|
|
91
111
|
'Write', 'Read', 'Edit', 'Bash', 'Glob', 'Grep', 'MultiEdit', 'LS',
|
|
92
112
|
'NotebookRead', 'TodoWrite', 'Task', 'WebSearch', 'WebFetch',
|
|
93
113
|
'WebAgent', 'NotebookEdit',
|
|
@@ -187,6 +207,69 @@ const PROFILES = {
|
|
|
187
207
|
minToolFingerprintMatch: 0.5,
|
|
188
208
|
},
|
|
189
209
|
},
|
|
210
|
+
'openmausbot': {
|
|
211
|
+
name: 'openmausbot',
|
|
212
|
+
// OpenMausBot (github.com/milind-soni/OpenMausBot) is a messaging app
|
|
213
|
+
// whose bots run as `openai-compat` engines against /v1/chat/completions
|
|
214
|
+
// (server/drivers/openai-chat.ts). It executes ONLY the tools it sent —
|
|
215
|
+
// its own harness (ask_user + whichever MCP servers the bot mounted) —
|
|
216
|
+
// and omits the `tools` key entirely when a bot has none. Without this
|
|
217
|
+
// profile a tool-less bot got the full IDE_SAFE_TOOLS loadout injected
|
|
218
|
+
// (the router only treats an *array* as an opt-out), fingerprinted as
|
|
219
|
+
// lynkr-injected, and the first Read/Bash call came back as "provider
|
|
220
|
+
// returned tool calls, but no tools are available for this turn".
|
|
221
|
+
injectTools: false,
|
|
222
|
+
// OpenMausBot truncates oversized tool results itself and appends a
|
|
223
|
+
// notice carrying a saved result id + offset for agents_tool_result_read.
|
|
224
|
+
// Lynkr's compressJSON rewrote a 15 KB Composio result to a 111-char key
|
|
225
|
+
// skeleton (2026-10-14), deleting that notice; the model then paged with
|
|
226
|
+
// a bad id and OpenMausBot failed the turn. Its tool results are data,
|
|
227
|
+
// not re-runnable command output — leave them intact.
|
|
228
|
+
compressToolResults: false,
|
|
229
|
+
baselineTools: new Set([
|
|
230
|
+
// Attached whenever the bot may ask the user a question
|
|
231
|
+
// (shared/ask-question.ts, pushed in openai-chat.ts).
|
|
232
|
+
'ask_user',
|
|
233
|
+
// The built-in "agents" MCP server (server/drivers/agents-catalog.ts),
|
|
234
|
+
// mounted by chat-mcp-tools.ts as `agents_<tool>`. Bot-scoped core
|
|
235
|
+
// loadout as of OpenMausBot 0.1.110; the feature-gated names (skills_*,
|
|
236
|
+
// shared computers, send_voice_note, chief-only, vm_exec on cloud
|
|
237
|
+
// homes) are left out — subtraction only removes names present.
|
|
238
|
+
'agents_tool_result_read', 'agents_list_room_targets',
|
|
239
|
+
'agents_coordinate_bots', 'agents_assignment_status',
|
|
240
|
+
'agents_list_bots', 'agents_list_rooms', 'agents_select_computer',
|
|
241
|
+
'agents_list_threads', 'agents_close_thread', 'agents_start_thread',
|
|
242
|
+
'agents_attach_file', 'agents_post_to_room',
|
|
243
|
+
'agents_request_credential', 'agents_request_sign_in',
|
|
244
|
+
'agents_fill_login', 'agents_memory_update', 'agents_memory_log',
|
|
245
|
+
'agents_session_search', 'agents_session_read',
|
|
246
|
+
'agents_list_routines', 'agents_propose_routine',
|
|
247
|
+
'agents_propose_routine_action', 'agents_propose_profile',
|
|
248
|
+
'agents_propose_model', 'agents_propose_team_memory',
|
|
249
|
+
'agents_add_mcp_server',
|
|
250
|
+
]),
|
|
251
|
+
detect: {
|
|
252
|
+
// OpenMausBot 0.1.x sends NO user-agent and no x-client header (its
|
|
253
|
+
// fetch sets only authorization + content-type), so today detection
|
|
254
|
+
// rests on the tool fingerprint and the prompt marker below. The
|
|
255
|
+
// header pattern is for a release that identifies itself, or an
|
|
256
|
+
// operator fronting the app with an `x-client: openmausbot` header.
|
|
257
|
+
headerPatterns: [/openmausbot/i],
|
|
258
|
+
// The app prepends a context note to the user turn whenever its
|
|
259
|
+
// volatile prompt sections changed (server/drivers/prompt-split.ts).
|
|
260
|
+
// Not on every turn, but the only stable "OpenMausBot" text a
|
|
261
|
+
// tool-less bot ever sends — best-effort, never a false positive.
|
|
262
|
+
promptPatterns: [
|
|
263
|
+
/Context from OpenMausBot updated since this conversation started/i,
|
|
264
|
+
/OpenMausBot context notes from earlier in this conversation/i,
|
|
265
|
+
],
|
|
266
|
+
// 0.5: memory can be off (-2 names), a non-chief bot sees the same
|
|
267
|
+
// propose_* names, and a bot whose agents server is unmounted sends
|
|
268
|
+
// just ask_user and will not match — correct: with tools present there
|
|
269
|
+
// is no injection to veto and nothing of ours to subtract.
|
|
270
|
+
minToolFingerprintMatch: 0.5,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
190
273
|
};
|
|
191
274
|
|
|
192
275
|
// Union of every baseline tool across all known profiles — used for the
|
|
@@ -203,7 +286,14 @@ function _knownBaselineTools() {
|
|
|
203
286
|
}
|
|
204
287
|
|
|
205
288
|
// User overrides / additions from data/client-profiles.json — loaded once
|
|
206
|
-
// at first use. Shape:
|
|
289
|
+
// at first use. Shape:
|
|
290
|
+
// { profileName: { baselineTools: [...], headerPatterns: [...],
|
|
291
|
+
// promptPatterns: [...], minToolFingerprintMatch: 0.8,
|
|
292
|
+
// toolless: false, injectTools: true,
|
|
293
|
+
// compressToolResults: true } }
|
|
294
|
+
// Every key is optional. An omitted key keeps the seed profile's value (or
|
|
295
|
+
// the default for a brand-new name), so overriding one field — say, a newer
|
|
296
|
+
// baseline for openmausbot — never silently drops its injectTools veto.
|
|
207
297
|
let _userProfilesLoaded = false;
|
|
208
298
|
function _loadUserProfiles() {
|
|
209
299
|
if (_userProfilesLoaded) return;
|
|
@@ -214,20 +304,32 @@ function _loadUserProfiles() {
|
|
|
214
304
|
const raw = JSON.parse(fs.readFileSync(overridePath, 'utf8'));
|
|
215
305
|
for (const [name, def] of Object.entries(raw || {})) {
|
|
216
306
|
if (!def || typeof def !== 'object') continue;
|
|
307
|
+
const seed = PROFILES[name];
|
|
217
308
|
const baseline = Array.isArray(def.baselineTools)
|
|
218
309
|
? new Set(def.baselineTools)
|
|
219
|
-
:
|
|
310
|
+
: seed?.baselineTools ?? new Set();
|
|
220
311
|
const patterns = Array.isArray(def.headerPatterns)
|
|
221
312
|
? def.headerPatterns.map((p) => new RegExp(p, 'i'))
|
|
222
|
-
:
|
|
313
|
+
: seed?.detect?.headerPatterns ?? [];
|
|
314
|
+
const promptPatterns = Array.isArray(def.promptPatterns)
|
|
315
|
+
? def.promptPatterns.map((p) => new RegExp(p, 'i'))
|
|
316
|
+
: seed?.detect?.promptPatterns ?? [];
|
|
317
|
+
const flag = (key) => (typeof def[key] === 'boolean' ? def[key] : seed?.[key]);
|
|
318
|
+
const toolless = flag('toolless');
|
|
319
|
+
const injectTools = flag('injectTools');
|
|
320
|
+
const compressToolResults = flag('compressToolResults');
|
|
223
321
|
PROFILES[name] = {
|
|
224
322
|
name,
|
|
323
|
+
...(toolless === undefined ? {} : { toolless }),
|
|
324
|
+
...(injectTools === undefined ? {} : { injectTools }),
|
|
325
|
+
...(compressToolResults === undefined ? {} : { compressToolResults }),
|
|
225
326
|
baselineTools: baseline,
|
|
226
327
|
detect: {
|
|
227
328
|
headerPatterns: patterns,
|
|
329
|
+
promptPatterns,
|
|
228
330
|
minToolFingerprintMatch: Number.isFinite(def.minToolFingerprintMatch)
|
|
229
331
|
? def.minToolFingerprintMatch
|
|
230
|
-
: 0.8,
|
|
332
|
+
: seed?.detect?.minToolFingerprintMatch ?? 0.8,
|
|
231
333
|
},
|
|
232
334
|
};
|
|
233
335
|
}
|
|
@@ -247,7 +349,10 @@ function _toolName(t) {
|
|
|
247
349
|
*
|
|
248
350
|
* Match order:
|
|
249
351
|
* 1. user-agent header regex — strongest signal, cheap.
|
|
250
|
-
* 2.
|
|
352
|
+
* 2. x-client / x-client-name header regex — same patterns; lets a client
|
|
353
|
+
* (or an operator fronting one) name itself when it cannot set a UA.
|
|
354
|
+
* 3. First user message vs. the profile's promptPatterns.
|
|
355
|
+
* 4. Tool-set fingerprint — ≥ minToolFingerprintMatch (default 80%) of the
|
|
251
356
|
* profile's baseline tool names must be present in payload.tools.
|
|
252
357
|
*
|
|
253
358
|
* @param {Object} args
|
|
@@ -259,10 +364,19 @@ function detectClient({ headers = {}, payload = {} } = {}) {
|
|
|
259
364
|
_loadUserProfiles();
|
|
260
365
|
|
|
261
366
|
const ua = String(headers['user-agent'] || headers['User-Agent'] || '');
|
|
262
|
-
|
|
367
|
+
const xClient = String(
|
|
368
|
+
headers['x-client'] || headers['X-Client']
|
|
369
|
+
|| headers['x-client-name'] || headers['X-Client-Name'] || ''
|
|
370
|
+
);
|
|
371
|
+
for (const header of [ua, xClient]) {
|
|
372
|
+
if (!header) continue;
|
|
263
373
|
for (const profile of Object.values(PROFILES)) {
|
|
264
|
-
for (const pattern of profile.detect
|
|
265
|
-
|
|
374
|
+
for (const pattern of (profile.detect?.headerPatterns || [])) {
|
|
375
|
+
try {
|
|
376
|
+
if (pattern.test(header)) return profile;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
logger.warn({ profile: profile.name, pattern: String(pattern), err: err.message }, 'Client profile header pattern failed');
|
|
379
|
+
}
|
|
266
380
|
}
|
|
267
381
|
}
|
|
268
382
|
}
|
|
@@ -344,6 +458,31 @@ function allToolsAreBaseline(payload) {
|
|
|
344
458
|
return true;
|
|
345
459
|
}
|
|
346
460
|
|
|
461
|
+
/**
|
|
462
|
+
* True when the detected profile says the client executes only the tools it
|
|
463
|
+
* sent, so the OpenAI-compat routers must not inject IDE_SAFE_TOOLS for it.
|
|
464
|
+
* A null / unrecognized profile never vetoes — injection for unrecognized
|
|
465
|
+
* tool-less clients is unchanged.
|
|
466
|
+
*
|
|
467
|
+
* @param {Object|null} profile
|
|
468
|
+
* @returns {boolean}
|
|
469
|
+
*/
|
|
470
|
+
function vetoesToolInjection(profile) {
|
|
471
|
+
return !!profile && profile.injectTools === false;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* True when the detected profile says this client's tool results must reach
|
|
476
|
+
* the model unmodified, so the orchestrator skips the tool-result compressor
|
|
477
|
+
* for the request. A null / unrecognized profile never vetoes.
|
|
478
|
+
*
|
|
479
|
+
* @param {Object|null} profile
|
|
480
|
+
* @returns {boolean}
|
|
481
|
+
*/
|
|
482
|
+
function vetoesToolResultCompression(profile) {
|
|
483
|
+
return !!profile && profile.compressToolResults === false;
|
|
484
|
+
}
|
|
485
|
+
|
|
347
486
|
/** Test helper — clear cached override state (unit tests reload from disk). */
|
|
348
487
|
function _resetForTests() {
|
|
349
488
|
_userProfilesLoaded = false;
|
|
@@ -355,5 +494,7 @@ module.exports = {
|
|
|
355
494
|
detectClient,
|
|
356
495
|
effectiveTools,
|
|
357
496
|
allToolsAreBaseline,
|
|
497
|
+
vetoesToolInjection,
|
|
498
|
+
vetoesToolResultCompression,
|
|
358
499
|
_resetForTests,
|
|
359
500
|
};
|
|
@@ -113,9 +113,30 @@ const FORCE_REASONING_PATTERNS = [
|
|
|
113
113
|
// and generic nouns (module/api/database) that misfire on non-technical
|
|
114
114
|
// asks. Known overlap: "explain the deployment plan" fires — accepted,
|
|
115
115
|
// deployment planning benefits from the strong model anyway.
|
|
116
|
-
|
|
116
|
+
// NOTE: the frontier-planning trigger lives below as two independent
|
|
117
|
+
// regexes (PLANNING_ASK_RE / TECHNICAL_SCOPE_RE), not in this array.
|
|
117
118
|
];
|
|
118
119
|
|
|
120
|
+
// Frontier planning (the removed array entry above): asking FOR a
|
|
121
|
+
// plan/strategy/design of technical work — a plan word AND a technical
|
|
122
|
+
// scope word must both be present, so "plan my vacation", "lesson plan"
|
|
123
|
+
// and "business plan" stay quiet while "give me a plan to refactor X"
|
|
124
|
+
// escalates. Implemented as two independent whole-content tests instead of
|
|
125
|
+
// a single (?=.*A)(?=.*B) lookahead regex: `.` never crosses a newline, so
|
|
126
|
+
// on long lines every start position rescans `.*` to end-of-line — O(n²).
|
|
127
|
+
// A single 200KB line measured 61s per call, ×2 calls per request via
|
|
128
|
+
// determineProviderSmart → 150s+ end-to-end (live incident 2026-10-15:
|
|
129
|
+
// /v1/messages 50k-token single-line probe). Two linear passes preserve
|
|
130
|
+
// the documented intent: co-occurrence anywhere in the ask, either order.
|
|
131
|
+
const PLANNING_ASK_RE =
|
|
132
|
+
/\b(plans?|roadmaps?|strateg(y|ies)|blueprints?|proposals?|rfcs?|design\s+docs?)\b/i;
|
|
133
|
+
const TECHNICAL_SCOPE_RE =
|
|
134
|
+
/\b(refactor\w*|implement\w*|migrat\w+|architect\w+|redesign\w*|restructur\w+|rewrit\w+|overhaul\w*|codebases?|infrastructure|pipelines?|deploy\w*|systems?)\b/i;
|
|
135
|
+
|
|
136
|
+
function isFrontierPlanningAsk(content) {
|
|
137
|
+
return PLANNING_ASK_RE.test(content) && TECHNICAL_SCOPE_RE.test(content);
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
// Force COMPLEX patterns - route to COMPLEX (GLM in config B) regardless of
|
|
120
141
|
// score. Architecture/refactor/code-review are substantial but not
|
|
121
142
|
// security-critical, so GLM is appropriate.
|
|
@@ -506,6 +527,10 @@ function scoreTaskType(content) {
|
|
|
506
527
|
}
|
|
507
528
|
}
|
|
508
529
|
|
|
530
|
+
if (isFrontierPlanningAsk(content)) {
|
|
531
|
+
return { score: 100, reason: 'force_reasoning', pattern: 'frontier_planning_two_sided' };
|
|
532
|
+
}
|
|
533
|
+
|
|
509
534
|
for (const pattern of FORCE_LOCAL_PATTERNS) {
|
|
510
535
|
if (pattern.test(content)) {
|
|
511
536
|
return { score: 0, reason: 'force_local', pattern: 'greeting_or_simple' };
|
|
@@ -1005,7 +1030,8 @@ function shouldForceCloud(payload) {
|
|
|
1005
1030
|
function shouldForceReasoning(payload) {
|
|
1006
1031
|
if (process.env.FORCE_TIER_PATTERNS === "false") return false;
|
|
1007
1032
|
const content = extractContent(payload);
|
|
1008
|
-
|
|
1033
|
+
let matched = FORCE_REASONING_PATTERNS.find((pattern) => pattern.test(content));
|
|
1034
|
+
if (!matched && isFrontierPlanningAsk(content)) matched = PLANNING_ASK_RE;
|
|
1009
1035
|
if (matched) {
|
|
1010
1036
|
logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_reasoning matched');
|
|
1011
1037
|
}
|
|
@@ -1159,6 +1185,9 @@ module.exports = {
|
|
|
1159
1185
|
FORCE_REASONING_PATTERNS,
|
|
1160
1186
|
FORCE_CLOUD_PATTERNS,
|
|
1161
1187
|
FORCE_LOCAL_PATTERNS,
|
|
1188
|
+
PLANNING_ASK_RE,
|
|
1189
|
+
TECHNICAL_SCOPE_RE,
|
|
1190
|
+
isFrontierPlanningAsk,
|
|
1162
1191
|
DIMENSION_WEIGHTS,
|
|
1163
1192
|
TOOL_COMPLEXITY_WEIGHTS,
|
|
1164
1193
|
DOMAIN_KEYWORDS,
|