lynkr 9.14.20 → 9.14.21

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.20",
3
+ "version": "9.14.21",
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",
@@ -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
- // Inject tools if client didn't send any.
555
- // Two-layer filtering:
556
- // 1. IDE_SAFE_TOOLS = STANDARD_TOOLS minus AskUserQuestion (can't work through proxy)
557
- // 2. For known clients (codex, cline, etc.), further filter to only tools
558
- // that have a mapping in CLIENT_TOOL_MAPPINGS — this ensures clients like
559
- // Codex don't see tools they can't handle (Task, WebFetch, NotebookEdit)
560
- // while Claude Code (unknown client) gets the full IDE_SAFE_TOOLS set.
561
- // Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
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
- if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
565
- const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
566
- let clientTools = clientMappings
567
- ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
568
- : IDE_SAFE_TOOLS;
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: clientMappings
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 — detect the client harness on the OpenAI-compat paths too (the
581
- // /v1/messages router already does this). Runs after tool injection so
582
- // the fingerprint sees the final tools array either way: goose/Cursor
583
- // send their own loadout, tool-less clients get IDE_SAFE_TOOLS injected
584
- // and fingerprint as claude-code. Without this the agentic detector
585
- // counts the harness baseline as intent and floors every request —
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
- // Inject tools if the Anthropic request has none.
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
- if (!clientDisabledToolChoice && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
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
- if (clientMappings) {
1885
- clientTools = clientTools.map(t => {
1886
- const mapping = clientMappings[t.name];
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: clientMappings
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 — detect the client harness on the OpenAI-compat paths too (the
1927
- // /v1/messages router already does this). Runs after tool injection so
1928
- // the fingerprint sees the final tools array either way: goose/Cursor
1929
- // send their own loadout, tool-less clients get IDE_SAFE_TOOLS injected
1930
- // and fingerprint as claude-code. Without this the agentic detector
1931
- // counts the harness baseline as intent and floors every request —
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
- if (config.toolResultCompression?.enabled !== false) {
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` header
15
- * and its tools list, `detectClient` returns the matching profile (or null),
16
- * and `effectiveTools` subtracts the baseline so the agentic detector scores
17
- * only tools the user actually added beyond the harness default.
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
- // injected by the OpenAI-compat routers when a client sends no tools.
85
- // These are Lynkr plumbing, not user intent: without this profile the
86
- // agentic detector counts its own injected tools and floors every
87
- // tool-less client (e.g. goose's summarizer side-requests) at
88
- // ITERATIVE/COMPLEX. Matches claude-code's names plus the four
89
- // (MultiEdit, LS, NotebookRead, WebAgent) that keep it from
90
- // fingerprinting as claude-code.
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: { profileName: { baselineTools: [...], detect: {...} } }
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
- : PROFILES[name]?.baselineTools ?? new Set();
310
+ : seed?.baselineTools ?? new Set();
220
311
  const patterns = Array.isArray(def.headerPatterns)
221
312
  ? def.headerPatterns.map((p) => new RegExp(p, 'i'))
222
- : PROFILES[name]?.detect?.headerPatterns ?? [];
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. Tool-set fingerprint — ≥ minToolFingerprintMatch (default 80%) of the
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
- if (ua) {
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.headerPatterns) {
265
- if (pattern.test(ua)) return profile;
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
  };