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.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +459 -146
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +144 -88
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +298 -10
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- 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
|
-
|
|
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
|
*/
|
package/src/agents/executor.js
CHANGED
|
@@ -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
|
-
//
|
|
407
|
-
|
|
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,
|
package/src/agents/index.js
CHANGED
|
@@ -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
|
/**
|
package/src/agents/reflector.js
CHANGED
|
@@ -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(
|
|
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 };
|