lynkr 9.7.2 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WS7.2 — replay real telemetry request texts through the anchor classifier
4
+ * and measure whether {trivial, substantive, heavyweight} separate cleanly
5
+ * in cosine space. This is the evidence gate for keeping the three-way
6
+ * blend (vs collapsing to a single cheap-vs-capable threshold).
7
+ *
8
+ * Usage: node scripts/ws7-anchor-replay.js [--db .lynkr/telemetry.db] [--limit N]
9
+ * Requires: local Ollama with the configured embeddings model.
10
+ *
11
+ * Reports:
12
+ * - per-class counts + score distribution
13
+ * - top-2 sim margin per class (median/p25) — the separation metric.
14
+ * Margin >= ~0.05 median = classes separate; < 0.02 = collapse to binary.
15
+ * - tier movement vs the recorded (lexical-era) tier.
16
+ */
17
+ const path = require('path');
18
+ const Database = require('better-sqlite3');
19
+
20
+ const args = process.argv.slice(2);
21
+ const dbPath = args.includes('--db')
22
+ ? args[args.indexOf('--db') + 1]
23
+ : path.join(__dirname, '../.lynkr/telemetry.db');
24
+ const limit = args.includes('--limit') ? Number(args[args.indexOf('--limit') + 1]) : 100000;
25
+
26
+ const MINING_SQL = `
27
+ SELECT DISTINCT request_text, tier, quality_score FROM routing_telemetry
28
+ WHERE request_text IS NOT NULL AND length(request_text)>3
29
+ AND request_text NOT LIKE '%system-reminder%'
30
+ AND request_text NOT LIKE '%SUGGESTION MODE%'
31
+ AND request_text NOT LIKE '%[Lynkr]%'
32
+ AND request_text NOT LIKE 'quota%'
33
+ AND request_text NOT LIKE '<session>%' AND request_text NOT LIKE '<conversation>%'
34
+ LIMIT ?`;
35
+
36
+ function pct(arr, p) {
37
+ if (!arr.length) return null;
38
+ const s = [...arr].sort((a, b) => a - b);
39
+ return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
40
+ }
41
+
42
+ async function main() {
43
+ const { scoreIntent, getDefaultCentroids } = require('../src/routing/intent-score');
44
+ const { getModelTierSelector } = require('../src/routing/model-tiers');
45
+
46
+ const centroids = await getDefaultCentroids();
47
+ if (!centroids) {
48
+ console.error('FATAL: could not build anchor centroids — is Ollama running with the embeddings model?');
49
+ process.exit(1);
50
+ }
51
+
52
+ const db = new Database(dbPath, { readonly: true });
53
+ const rows = db.prepare(MINING_SQL).all(limit);
54
+ console.log(`Mined ${rows.length} distinct request texts from ${dbPath}\n`);
55
+
56
+ const selector = getModelTierSelector();
57
+ const byClass = {};
58
+ const margins = { trivial: [], substantive: [], heavyweight: [] };
59
+ const moves = {};
60
+ let anchorMode = 0;
61
+ let lexicalFallback = 0;
62
+
63
+ for (const row of rows) {
64
+ const r = await scoreIntent({ messages: [{ role: 'user', content: row.request_text }] });
65
+ if (!r) continue;
66
+ if (r.mode !== 'anchor') { lexicalFallback++; continue; }
67
+ anchorMode++;
68
+ const cls = r.class;
69
+ byClass[cls] = byClass[cls] || { n: 0, scores: [], examples: [] };
70
+ byClass[cls].n++;
71
+ byClass[cls].scores.push(r.score);
72
+ if (byClass[cls].examples.length < 4) {
73
+ byClass[cls].examples.push(`${r.score.toString().padStart(2)} ${row.request_text.slice(0, 90).replace(/\n/g, ' ')}`);
74
+ }
75
+ const sims = Object.entries(r.sims).sort((a, b) => b[1] - a[1]);
76
+ margins[cls].push(sims[0][1] - sims[1][1]);
77
+
78
+ const newTier = selector.getTier(r.score);
79
+ const oldTier = row.tier || '??';
80
+ const key = `${oldTier} -> ${newTier}`;
81
+ moves[key] = (moves[key] || 0) + 1;
82
+ }
83
+
84
+ console.log(`Scored: ${anchorMode} anchor-mode, ${lexicalFallback} lexical-fallback\n`);
85
+ console.log('=== Class distribution + score stats ===');
86
+ for (const [cls, d] of Object.entries(byClass)) {
87
+ console.log(`\n${cls}: n=${d.n} score median=${pct(d.scores, 50)} p10=${pct(d.scores, 10)} p90=${pct(d.scores, 90)}`);
88
+ console.log(` top-2 sim margin: median=${pct(margins[cls], 50)?.toFixed(4)} p25=${pct(margins[cls], 25)?.toFixed(4)} min=${Math.min(...margins[cls]).toFixed(4)}`);
89
+ for (const e of d.examples) console.log(` ${e}`);
90
+ }
91
+
92
+ console.log('\n=== Tier movement (recorded lexical-era tier -> anchor tier) ===');
93
+ for (const [k, v] of Object.entries(moves).sort((a, b) => b[1] - a[1])) {
94
+ console.log(` ${k}: ${v}`);
95
+ }
96
+
97
+ const allMargins = Object.values(margins).flat();
98
+ const med = pct(allMargins, 50);
99
+ console.log(`\n=== VERDICT ===`);
100
+ console.log(`Overall top-2 sim margin: median=${med?.toFixed(4)} p25=${pct(allMargins, 25)?.toFixed(4)}`);
101
+ console.log(med >= 0.05
102
+ ? 'Classes separate cleanly (median margin >= 0.05) — three-way blend is supported by the data.'
103
+ : med >= 0.02
104
+ ? 'Borderline separation — keep three-way but watch misroutes; consider more anchors.'
105
+ : 'Classes do NOT separate — collapse to binary (trivial vs rest).');
106
+ }
107
+
108
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -0,0 +1,195 @@
1
+ ---
2
+ name: lynkr
3
+ display_name: Lynkr AI Routing Proxy
4
+ version: 8.0.1
5
+ description: Universal LLM gateway with intelligent routing, Graphify code intelligence, Distill compression, routing telemetry, Code Mode, and 12+ provider support. 60-80% cost reduction for Claude Code, Cursor, and Codex.
6
+ author: lynkr-ai
7
+ license: MIT
8
+ tags:
9
+ - routing
10
+ - proxy
11
+ - llm
12
+ - multi-provider
13
+ - ollama
14
+ - openai
15
+ - anthropic
16
+ - bedrock
17
+ - cost-optimization
18
+ - graphify
19
+ - distill
20
+ - telemetry
21
+ - code-mode
22
+ - compression
23
+ - memory
24
+ category: infrastructure
25
+ homepage: https://github.com/Fast-Editor/Lynkr
26
+ npm: lynkr
27
+ requires:
28
+ node: ">=20"
29
+ providers:
30
+ - ollama
31
+ - databricks
32
+ - azure-anthropic
33
+ - azure-openai
34
+ - openrouter
35
+ - openai
36
+ - bedrock
37
+ - vertex
38
+ - moonshot
39
+ - zai
40
+ - llamacpp
41
+ - lmstudio
42
+ - codex
43
+ - deepseek
44
+ ---
45
+
46
+ # Lynkr - Universal LLM Gateway
47
+
48
+ Lynkr routes AI coding requests to the optimal model based on task complexity, cost, and provider health. Supports 12+ providers with 60-80% cost reduction through intelligent token optimization.
49
+
50
+ ## Quick Start
51
+
52
+ ```bash
53
+ npm install -g lynkr
54
+ lynkr-setup # Auto-installs Ollama + pulls a model
55
+ lynkr # Start the proxy
56
+ ```
57
+
58
+ Then point your AI coding tool at `http://localhost:8081/v1`.
59
+
60
+ ## How It Works
61
+
62
+ 1. **5-Phase Complexity Analysis** - Scores each request 0-100 using token count, tool usage, code patterns, domain keywords, and Graphify structural analysis (god nodes, community cohesion, blast radius)
63
+ 2. **4-Tier Routing** - Maps score to SIMPLE/MEDIUM/COMPLEX/REASONING, each with a configured provider:model
64
+ 3. **Agentic Detection** - Detects multi-step workflows (tool loops, autonomous agents) and upgrades to higher tiers
65
+ 4. **Cost Optimization** - Picks the cheapest provider that can handle the tier
66
+ 5. **Circuit Breaker + Failover** - Automatic failover with half-open probe recovery
67
+
68
+ ## Key Features (v8.0)
69
+
70
+ ### Intelligent Routing
71
+ - 5-phase complexity scoring with 15-dimension weighted mode
72
+ - Agentic workflow detection (SINGLE_SHOT / TOOL_CHAIN / ITERATIVE / AUTONOMOUS)
73
+ - Graphify knowledge graph integration — god node detection, community cohesion, blast radius
74
+ - Routing telemetry with SQLite store, quality scoring (0-100), latency tracking (P50/P95/P99)
75
+
76
+ ### Token Optimization (60-80% savings)
77
+ - **Smart tool selection** — filters tools by request type
78
+ - **Distill compression** — structural similarity (Jaccard), delta rendering, block dedup
79
+ - **Code Mode** — replaces 100+ MCP tools with 4 meta-tools (~96% token reduction)
80
+ - **History compression** — sliding window with Distill-powered dedup
81
+ - **Prompt caching** — SHA-256 keyed LRU cache
82
+ - **Headroom sidecar** — optional 47-92% compression via Smart Crusher, CCR, LLMLingua
83
+
84
+ ### Production Hardening
85
+ - Circuit breakers with half-open probe recovery
86
+ - Admin hot-reload endpoint (POST /v1/admin/reload) — no restart needed
87
+ - Per-request performance timing (PERF_TIMER=true)
88
+ - Prometheus metrics, structured logging, health checks
89
+ - Rate limiting, load shedding, input validation
90
+
91
+ ### Long-Term Memory (Titans-Inspired)
92
+ - Surprise-based memory storage with decay
93
+ - Semantic search via FTS5
94
+ - Automatic extraction and injection
95
+
96
+ ## Configuration for OpenClaw
97
+
98
+ Set tier routing in your environment:
99
+
100
+ ```env
101
+ MODEL_PROVIDER=ollama
102
+ TIER_SIMPLE=ollama:llama3.2
103
+ TIER_MEDIUM=openrouter:anthropic/claude-sonnet-4
104
+ TIER_COMPLEX=bedrock:anthropic.claude-sonnet-4-20250514-v1:0
105
+ TIER_REASONING=bedrock:anthropic.claude-opus-4-20250514-v1:0
106
+ ```
107
+
108
+ ### OpenClaw Mode
109
+
110
+ When running under OpenClaw, enable model name rewriting:
111
+
112
+ ```env
113
+ OPENCLAW_MODE=true
114
+ ```
115
+
116
+ This replaces the generic `model: "auto"` in responses with the actual `provider/model` that handled the request.
117
+
118
+ ## Provider Registration
119
+
120
+ Add to your `openclaw.json`:
121
+
122
+ ```json
123
+ {
124
+ "models": {
125
+ "providers": [
126
+ {
127
+ "name": "lynkr",
128
+ "type": "openai-compatible",
129
+ "base_url": "http://localhost:8081/v1",
130
+ "api_key": "any-value",
131
+ "models": ["auto"]
132
+ }
133
+ ]
134
+ },
135
+ "agents": {
136
+ "defaults": {
137
+ "models": {
138
+ "primary": "lynkr/auto",
139
+ "fallback": "lynkr/auto"
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ ## Providers
147
+
148
+ | Provider | Type | Models |
149
+ |----------|------|--------|
150
+ | Ollama | Local (free) | llama3.2, qwen2.5-coder, deepseek-coder, mistral |
151
+ | llama.cpp | Local (free) | Any GGUF model |
152
+ | LM Studio | Local (free) | Any downloaded model |
153
+ | OpenAI | Cloud | gpt-4o, o3, o4-mini |
154
+ | Anthropic | Cloud | claude-opus-4, claude-sonnet-4, claude-haiku-4.5 |
155
+ | Databricks | Cloud | Claude, GPT, Llama via Foundation Model APIs |
156
+ | AWS Bedrock | Cloud | Claude, Titan, Llama, Mistral |
157
+ | Azure OpenAI | Cloud | GPT-4o, o1, o3 |
158
+ | OpenRouter | Cloud | 100+ models |
159
+ | Google Vertex | Cloud | Gemini 2.5 Pro/Flash |
160
+ | Moonshot AI | Cloud | Kimi K2 Thinking/Turbo |
161
+ | Z.AI | Cloud | GLM-4.7 |
162
+ | DeepSeek | Cloud | DeepSeek Reasoner, R1 |
163
+
164
+ ## New in v8.0
165
+
166
+ - **Graphify Integration** — AST-based knowledge graph with 19-language support for blast radius analysis
167
+ - **Distill Compression** — Structural similarity, delta rendering, and smart dedup
168
+ - **Routing Telemetry** — SQLite-backed decision recording with quality scoring
169
+ - **Code Mode** — 4 MCP meta-tools replace 100+ individual definitions
170
+ - **Admin Reload** — Hot-reload config + reset circuit breakers without restart
171
+ - **Performance Timer** — Per-request timing breakdown (PERF_TIMER=true)
172
+ - **Large Payload Passthrough** — Smart cloning skips base64 media that will be discarded
173
+
174
+ ## Response Headers
175
+
176
+ | Header | Description |
177
+ |--------|-------------|
178
+ | `X-Lynkr-Provider` | Provider that handled the request |
179
+ | `X-Lynkr-Model` | Model used |
180
+ | `X-Lynkr-Tier` | Complexity tier (SIMPLE/MEDIUM/COMPLEX/REASONING) |
181
+ | `X-Lynkr-Complexity-Score` | Numeric score 0-100 |
182
+ | `X-Lynkr-Routing-Method` | How the route was decided |
183
+ | `X-Lynkr-Agentic` | Agentic workflow type (if detected) |
184
+ | `X-Lynkr-Cost-Optimized` | Whether cost optimization changed the provider |
185
+
186
+ ## Telemetry Endpoints
187
+
188
+ | Endpoint | Description |
189
+ |----------|-------------|
190
+ | `GET /v1/routing/stats` | Aggregated routing stats with latency percentiles |
191
+ | `GET /v1/routing/stats/:provider` | Per-provider statistics |
192
+ | `GET /v1/routing/telemetry` | Raw telemetry records |
193
+ | `GET /v1/routing/accuracy` | Over/under-provisioned routing detection |
194
+ | `POST /v1/admin/reload` | Hot-reload config + reset circuit breakers |
195
+ | `POST /v1/admin/circuit-breakers/reset` | Reset circuit breakers |
@@ -62,6 +62,14 @@ class ContextManager {
62
62
  allowedTools: agentDef.allowedTools,
63
63
  startTime: Date.now(),
64
64
 
65
+ // Original task prompt — consumed by the Reflector to infer task type.
66
+ taskPrompt,
67
+
68
+ // In-memory transcript mirror of the JSONL file — consumed by the
69
+ // Reflector for tool-usage / error-recovery analysis. The JSONL file
70
+ // remains the durable record; this array is the hot-path copy.
71
+ transcript: [],
72
+
65
73
  // Token tracking
66
74
  inputTokens: 0,
67
75
  outputTokens: 0,
@@ -102,7 +110,7 @@ class ContextManager {
102
110
  * Record tool execution in transcript
103
111
  */
104
112
  recordToolCall(context, toolName, input, output, error = null) {
105
- this.writeTranscriptEntry(context.transcriptPath, {
113
+ const entry = {
106
114
  type: "tool_call",
107
115
  agentId: context.agentId,
108
116
  step: context.steps,
@@ -111,7 +119,15 @@ class ContextManager {
111
119
  output: error ? null : output,
112
120
  error: error ? error.message : null,
113
121
  timestamp: Date.now()
114
- });
122
+ };
123
+
124
+ // Keep the in-memory transcript in sync so the Reflector (which reads
125
+ // context.transcript) sees tool calls without re-parsing the JSONL file.
126
+ if (Array.isArray(context.transcript)) {
127
+ context.transcript.push(entry);
128
+ }
129
+
130
+ this.writeTranscriptEntry(context.transcriptPath, entry);
115
131
  }
116
132
 
117
133
  /**
@@ -4,11 +4,17 @@ const yaml = require("js-yaml");
4
4
  const logger = require("../../logger");
5
5
  const Skillbook = require("../skillbook");
6
6
 
7
+ // How often to prune low-quality learned skills. 6 hours is frequent enough to
8
+ // keep skillbooks tidy on a long-running process but rare enough to be
9
+ // negligible overhead. Hardcoded on purpose — not worth a config knob.
10
+ const SKILL_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000;
11
+
7
12
  class AgentDefinitionLoader {
8
13
  constructor() {
9
14
  this.agents = new Map();
10
15
  this.skillbooks = new Map(); // agentType → Skillbook
11
16
  this.initialized = false;
17
+ this.pruneTimer = null;
12
18
 
13
19
  // Initialize synchronously for compatibility
14
20
  this.loadBuiltInAgentsSync();
@@ -82,6 +88,27 @@ class AgentDefinitionLoader {
82
88
  return this.skillbooks.get(agentType);
83
89
  }
84
90
 
91
+ /**
92
+ * Replace an agent's skillbook with a freshly-learned instance and re-inject
93
+ * its skills into the (in-memory) system prompt. Called by the executor after
94
+ * a run persists new skills, so learning takes effect within the running
95
+ * process instead of only after a restart. Injection rebuilds from
96
+ * originalSystemPrompt, so repeated calls do not duplicate the skills block.
97
+ */
98
+ setSkillbook(agentType, skillbook) {
99
+ if (!agentType || !skillbook) return;
100
+ // Match the case-insensitive key an agent is actually stored under.
101
+ const key = this.agents.has(agentType)
102
+ ? agentType
103
+ : Array.from(this.agents.keys()).find(
104
+ k => k.toLowerCase() === String(agentType).toLowerCase()
105
+ );
106
+ if (!key) return;
107
+
108
+ this.skillbooks.set(key, skillbook);
109
+ this._injectSkillsIntoPrompt(key);
110
+ }
111
+
85
112
  /**
86
113
  * Reload skillbooks and update prompts (call after learning)
87
114
  */
@@ -89,6 +116,69 @@ class AgentDefinitionLoader {
89
116
  await this._loadSkillbooksAsync();
90
117
  }
91
118
 
119
+ /**
120
+ * Prune low-quality skills from every loaded skillbook. Skillbook.prune()
121
+ * only drops skills that have been tried enough times to prove they don't
122
+ * help (default: useCount >= 3 && confidence < 0.2), so fresh skills are
123
+ * never removed. Persists and re-injects only the skillbooks that changed.
124
+ * @returns {Promise<number>} total skills pruned across all agents
125
+ */
126
+ async pruneSkillbooks() {
127
+ let totalPruned = 0;
128
+
129
+ for (const [agentType, skillbook] of this.skillbooks.entries()) {
130
+ try {
131
+ const pruned = skillbook.prune();
132
+ if (pruned > 0) {
133
+ await skillbook.save();
134
+ this._injectSkillsIntoPrompt(agentType);
135
+ totalPruned += pruned;
136
+ }
137
+ } catch (error) {
138
+ logger.warn(
139
+ { agentType, error: error.message },
140
+ "Failed to prune skillbook"
141
+ );
142
+ }
143
+ }
144
+
145
+ if (totalPruned > 0) {
146
+ logger.info({ totalPruned }, "Pruned low-quality agent skills");
147
+ }
148
+ return totalPruned;
149
+ }
150
+
151
+ /**
152
+ * Start periodic skillbook pruning. Idempotent; a non-positive interval
153
+ * disables pruning. The timer is unref'd so it never keeps the process alive.
154
+ */
155
+ startSkillPruning(intervalMs = SKILL_PRUNE_INTERVAL_MS) {
156
+ if (this.pruneTimer) return; // already running
157
+ if (!Number.isInteger(intervalMs) || intervalMs <= 0) {
158
+ logger.debug({ intervalMs }, "Skillbook pruning disabled");
159
+ return;
160
+ }
161
+
162
+ this.pruneTimer = setInterval(() => {
163
+ this.pruneSkillbooks().catch((error) => {
164
+ logger.warn({ error: error.message }, "Skillbook pruning failed");
165
+ });
166
+ }, intervalMs);
167
+ this.pruneTimer.unref();
168
+
169
+ logger.info({ intervalMs }, "Skillbook pruning started");
170
+ }
171
+
172
+ /**
173
+ * Stop periodic skillbook pruning (for shutdown / tests).
174
+ */
175
+ stopSkillPruning() {
176
+ if (this.pruneTimer) {
177
+ clearInterval(this.pruneTimer);
178
+ this.pruneTimer = null;
179
+ }
180
+ }
181
+
92
182
  /**
93
183
  * Load built-in agents (Explore, Plan, General)
94
184
  */
@@ -9,6 +9,18 @@ const Reflector = require("./reflector");
9
9
  const contextManager = new ContextManager();
10
10
 
11
11
  class SubagentExecutor {
12
+ /**
13
+ * @param {Object} [options]
14
+ * @param {Object} [options.definitionLoader] - Agent definition loader. When
15
+ * provided, newly-learned skills are re-injected into agent prompts live
16
+ * (within the running process). Optional so the executor still works
17
+ * standalone (e.g. in tests); without it, learning still persists to disk
18
+ * and is picked up on the next process start.
19
+ */
20
+ constructor({ definitionLoader = null } = {}) {
21
+ this.definitionLoader = definitionLoader;
22
+ }
23
+
12
24
  /**
13
25
  * Execute a single subagent
14
26
  * @param {Object} agentDef - Agent definition
@@ -403,8 +415,12 @@ class SubagentExecutor {
403
415
  return; // Nothing to learn
404
416
  }
405
417
 
406
- // Load skillbook for this agent type
407
- const skillbook = await Skillbook.load(context.agentName);
418
+ // Prefer the loader's live skillbook instance (so use-counts/confidence
419
+ // accumulate in one place); fall back to loading from disk when running
420
+ // standalone.
421
+ const skillbook =
422
+ this.definitionLoader?.getSkillbook(context.agentName) ||
423
+ (await Skillbook.load(context.agentName));
408
424
 
409
425
  // Add each learned pattern
410
426
  for (const pattern of patterns) {
@@ -414,6 +430,12 @@ class SubagentExecutor {
414
430
  // Save skillbook (persists learning)
415
431
  await skillbook.save();
416
432
 
433
+ // Re-inject into the agent's prompt so the next run benefits without a
434
+ // restart. No-op when no loader was provided.
435
+ if (this.definitionLoader) {
436
+ this.definitionLoader.setSkillbook(context.agentName, skillbook);
437
+ }
438
+
417
439
  logger.info({
418
440
  agentType: context.agentName,
419
441
  patternsLearned: patterns.length,
@@ -5,7 +5,15 @@ const ParallelCoordinator = require("./parallel-coordinator");
5
5
  const agentStore = require("./store");
6
6
 
7
7
  const definitionLoader = new AgentDefinitionLoader();
8
- const coordinator = new ParallelCoordinator(config.agents?.maxConcurrent || 10);
8
+ const coordinator = new ParallelCoordinator(config.agents?.maxConcurrent || 10, {
9
+ definitionLoader,
10
+ });
11
+
12
+ // Periodically drop learned skills that have proven unhelpful. Only when the
13
+ // agents subsystem is active; the timer is unref'd so it never blocks exit.
14
+ if (config.agents?.enabled) {
15
+ definitionLoader.startSkillPruning();
16
+ }
9
17
 
10
18
  /**
11
19
  * Spawn and execute subagent(s)
@@ -2,9 +2,9 @@ const logger = require("../logger");
2
2
  const SubagentExecutor = require("./executor");
3
3
 
4
4
  class ParallelCoordinator {
5
- constructor(maxConcurrent = 10) {
5
+ constructor(maxConcurrent = 10, { definitionLoader = null } = {}) {
6
6
  this.maxConcurrent = maxConcurrent;
7
- this.executor = new SubagentExecutor();
7
+ this.executor = new SubagentExecutor({ definitionLoader });
8
8
  }
9
9
 
10
10
  /**
@@ -184,8 +184,13 @@ class Reflector {
184
184
  // Pattern 1: Recovered from errors
185
185
  if (successful) {
186
186
  const failedTools = errorEntries.map(e => e.toolName);
187
+ // Tools invoked *after* the last error entry are the recovery path.
188
+ // Use the entry's position in the transcript, not its timestamp
189
+ // (slicing by a ~1.7e12 timestamp always yielded []).
190
+ const lastErrorEntry = errorEntries[errorEntries.length - 1];
191
+ const lastErrorIndex = transcript.indexOf(lastErrorEntry);
187
192
  const recoveryTools = transcript
188
- .slice(errorEntries[errorEntries.length - 1].timestamp)
193
+ .slice(lastErrorIndex + 1)
189
194
  .filter(e => e.type === "tool_call" && !e.error)
190
195
  .map(e => e.toolName);
191
196
 
@@ -248,6 +253,11 @@ class Reflector {
248
253
  * Infer task type from prompt
249
254
  */
250
255
  static _inferTaskType(prompt) {
256
+ // Guard against a missing/non-string prompt so reflection never throws
257
+ // (a throw here previously aborted all learning silently).
258
+ if (typeof prompt !== "string" || prompt.length === 0) {
259
+ return null;
260
+ }
251
261
  const lower = prompt.toLowerCase();
252
262
 
253
263
  const taskTypes = [
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Loop guard — runaway-agent circuit breaker (WS: multi-agent cost controls).
3
+ *
4
+ * Agent frameworks (LangGraph, CrewAI, AutoGen) resend the full growing
5
+ * conversation every round-trip, so an uncapped tool loop compounds: each
6
+ * turn pays for every previous turn again. Real-world failure mode this
7
+ * guards: an AutoGen deployment ran 40% over budget in its first month from
8
+ * exactly this (uncapped reviewer<->coder loops).
9
+ *
10
+ * The guard is STATELESS — it reads loop depth from the incoming payload
11
+ * itself rather than tracking sessions:
12
+ * - message count ≈ total turns so far
13
+ * - tool_result count ≈ tool-loop iterations so far
14
+ * Both survive proxy restarts and need no session affinity.
15
+ *
16
+ * Disabled by default (caps unset/0). Enable per deployment:
17
+ * LYNKR_MAX_SESSION_TURNS=80 reject when messages[] exceeds 80 entries
18
+ * LYNKR_MAX_TOOL_TURNS=25 reject when tool_result blocks exceed 25
19
+ *
20
+ * Rejections use 429 + a machine-readable error type so agent frameworks
21
+ * surface them as a hard stop instead of retrying blindly.
22
+ */
23
+
24
+ const logger = require("../../logger");
25
+
26
+ function _countToolResults(messages) {
27
+ let n = 0;
28
+ for (const m of messages) {
29
+ if (!Array.isArray(m?.content)) continue;
30
+ for (const block of m.content) {
31
+ if (block?.type === "tool_result") n++;
32
+ }
33
+ }
34
+ return n;
35
+ }
36
+
37
+ function _cap(name) {
38
+ const v = Number(process.env[name]);
39
+ return Number.isFinite(v) && v > 0 ? Math.floor(v) : null;
40
+ }
41
+
42
+ function loopGuard(req, res, next) {
43
+ const maxTurns = _cap("LYNKR_MAX_SESSION_TURNS");
44
+ const maxToolTurns = _cap("LYNKR_MAX_TOOL_TURNS");
45
+ if (!maxTurns && !maxToolTurns) return next();
46
+
47
+ const messages = Array.isArray(req.body?.messages) ? req.body.messages : [];
48
+
49
+ if (maxTurns && messages.length > maxTurns) {
50
+ logger.warn(
51
+ { messageCount: messages.length, cap: maxTurns, path: req.path },
52
+ "[LoopGuard] conversation exceeded turn cap"
53
+ );
54
+ return res.status(429).json({
55
+ error: {
56
+ type: "loop_cap_exceeded",
57
+ message:
58
+ `Conversation has ${messages.length} messages, over the configured ` +
59
+ `cap of ${maxTurns} (LYNKR_MAX_SESSION_TURNS). This usually means an ` +
60
+ `agent loop is not converging — inspect the conversation before raising the cap.`,
61
+ },
62
+ });
63
+ }
64
+
65
+ if (maxToolTurns) {
66
+ const toolResults = _countToolResults(messages);
67
+ if (toolResults > maxToolTurns) {
68
+ logger.warn(
69
+ { toolResults, cap: maxToolTurns, path: req.path },
70
+ "[LoopGuard] conversation exceeded tool-turn cap"
71
+ );
72
+ return res.status(429).json({
73
+ error: {
74
+ type: "loop_cap_exceeded",
75
+ message:
76
+ `Conversation carries ${toolResults} tool results, over the configured ` +
77
+ `cap of ${maxToolTurns} (LYNKR_MAX_TOOL_TURNS). This usually means a ` +
78
+ `tool loop is stuck — inspect the last few tool calls before raising the cap.`,
79
+ },
80
+ });
81
+ }
82
+ }
83
+
84
+ return next();
85
+ }
86
+
87
+ module.exports = { loopGuard, _countToolResults };