@wrongstack/plugins 0.277.1 → 0.280.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 +838 -0
- package/dist/auto-doc.d.ts +8 -0
- package/dist/auto-doc.js +175 -13
- package/dist/auto-escalate.d.ts +45 -0
- package/dist/auto-escalate.js +190 -0
- package/dist/branch-guard.d.ts +33 -0
- package/dist/branch-guard.js +228 -0
- package/dist/changelog-writer.d.ts +73 -0
- package/dist/changelog-writer.js +369 -0
- package/dist/checkpoint.d.ts +55 -0
- package/dist/checkpoint.js +305 -0
- package/dist/commit-validator.d.ts +33 -0
- package/dist/commit-validator.js +315 -0
- package/dist/config-validator.d.ts +48 -0
- package/dist/config-validator.js +347 -0
- package/dist/context-pins.d.ts +45 -0
- package/dist/context-pins.js +240 -0
- package/dist/cost-tracker.d.ts +40 -1
- package/dist/cost-tracker.js +105 -4
- package/dist/dep-guard.d.ts +65 -0
- package/dist/dep-guard.js +316 -0
- package/dist/diff-summary.d.ts +36 -0
- package/dist/diff-summary.js +235 -0
- package/dist/error-lens.d.ts +67 -0
- package/dist/error-lens.js +280 -0
- package/dist/format-on-save.d.ts +35 -0
- package/dist/format-on-save.js +219 -0
- package/dist/git-autocommit.js +186 -26
- package/dist/import-organizer.d.ts +52 -0
- package/dist/import-organizer.js +274 -0
- package/dist/index.d.ts +32 -6
- package/dist/index.js +10151 -1628
- package/dist/injection-shield.d.ts +49 -0
- package/dist/injection-shield.js +205 -0
- package/dist/lint-gate.d.ts +33 -0
- package/dist/lint-gate.js +394 -0
- package/dist/llm-cache.d.ts +56 -0
- package/dist/llm-cache.js +251 -0
- package/dist/loop-breaker.d.ts +43 -0
- package/dist/loop-breaker.js +241 -0
- package/dist/model-router.d.ts +69 -0
- package/dist/model-router.js +198 -0
- package/dist/notify-hub.d.ts +45 -0
- package/dist/notify-hub.js +304 -0
- package/dist/path-guard.d.ts +54 -0
- package/dist/path-guard.js +235 -0
- package/dist/prompt-firewall.d.ts +57 -0
- package/dist/prompt-firewall.js +290 -0
- package/dist/secret-scanner.d.ts +34 -0
- package/dist/secret-scanner.js +409 -0
- package/dist/semver-bump.js +45 -0
- package/dist/session-recap.d.ts +50 -0
- package/dist/session-recap.js +421 -0
- package/dist/shell-check.js +52 -4
- package/dist/spec-linker.d.ts +51 -0
- package/dist/spec-linker.js +541 -0
- package/dist/template-engine.js +19 -1
- package/dist/test-runner-gate.d.ts +37 -0
- package/dist/test-runner-gate.js +356 -0
- package/dist/todo-listener.d.ts +37 -0
- package/dist/todo-listener.js +216 -0
- package/dist/todo-tracker.d.ts +5 -0
- package/dist/todo-tracker.js +441 -0
- package/dist/token-budget.d.ts +40 -0
- package/dist/token-budget.js +254 -0
- package/dist/token-throttle.d.ts +54 -0
- package/dist/token-throttle.js +203 -0
- package/package.json +116 -12
- package/dist/json-path.d.ts +0 -18
- package/dist/json-path.js +0 -15
- package/dist/web-search.d.ts +0 -19
- package/dist/web-search.js +0 -15
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Plugin } from '@wrongstack/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* llm-cache plugin — caches identical provider requests and skips the
|
|
5
|
+
* provider call entirely on a hit.
|
|
6
|
+
*
|
|
7
|
+
* This is the deepest intervention a plugin can make: it registers an
|
|
8
|
+
* `AgentExtension.wrapProviderRunner`, which the agent loop composes
|
|
9
|
+
* around EVERY provider call (`agent-loop.ts` → `extensions
|
|
10
|
+
* .wrapProviderRunner(baseRunner)`). The plugin fingerprints the
|
|
11
|
+
* outgoing `Request` (model + system + messages + tools + sampling
|
|
12
|
+
* params) and, on a match, returns the cached `Response` without ever
|
|
13
|
+
* touching the network — killing redundant calls from retries, loops,
|
|
14
|
+
* and re-runs.
|
|
15
|
+
*
|
|
16
|
+
* Safety posture: caching changes call semantics (a repeated request
|
|
17
|
+
* returns the same answer instead of a fresh sample), so the plugin is
|
|
18
|
+
* **opt-in** — it loads inert and does nothing until
|
|
19
|
+
* `config.extensions['llm-cache'].enabled = true`. By default it only
|
|
20
|
+
* caches deterministic requests (`temperature` 0 or unset) so a
|
|
21
|
+
* sampled generation is never silently frozen.
|
|
22
|
+
*
|
|
23
|
+
* Config (`config.extensions['llm-cache']`):
|
|
24
|
+
*
|
|
25
|
+
* ```jsonc
|
|
26
|
+
* {
|
|
27
|
+
* "enabled": false, // master switch (default OFF)
|
|
28
|
+
* "maxEntries": 256, // LRU capacity
|
|
29
|
+
* "ttlMs": 0, // 0 = no expiry; else evict after N ms
|
|
30
|
+
* "onlyDeterministic": true, // only cache temperature 0/unset requests
|
|
31
|
+
* "zeroUsageOnHit": false // report 0 tokens on a hit (true) or keep
|
|
32
|
+
* // the cached usage for context accounting (false)
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Tools:
|
|
37
|
+
* - `llm_cache_status` — hit/miss/skip counters, size, config
|
|
38
|
+
* - `llm_cache_clear` — drop all cached entries
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a request is safe to cache under `onlyDeterministic`. Sampled
|
|
45
|
+
* requests (temperature > 0) return different content each call, so
|
|
46
|
+
* caching them would silently freeze the first sample.
|
|
47
|
+
*/
|
|
48
|
+
declare function isDeterministic(request: Record<string, unknown>): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Stable fingerprint of the request fields that affect the response.
|
|
51
|
+
* Excludes anything cosmetic (user id, etc.). Same inputs → same key.
|
|
52
|
+
*/
|
|
53
|
+
declare function fingerprintRequest(request: Record<string, unknown>): string;
|
|
54
|
+
declare const plugin: Plugin;
|
|
55
|
+
|
|
56
|
+
export { plugin as default, fingerprintRequest, isDeterministic };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/llm-cache/index.ts
|
|
4
|
+
var state = {
|
|
5
|
+
cache: /* @__PURE__ */ new Map(),
|
|
6
|
+
hits: 0,
|
|
7
|
+
misses: 0,
|
|
8
|
+
skips: 0,
|
|
9
|
+
evictions: 0,
|
|
10
|
+
savedInputTokens: 0,
|
|
11
|
+
savedOutputTokens: 0,
|
|
12
|
+
extensionUnregister: null
|
|
13
|
+
};
|
|
14
|
+
var DEFAULTS = {
|
|
15
|
+
enabled: false,
|
|
16
|
+
maxEntries: 256,
|
|
17
|
+
ttlMs: 0,
|
|
18
|
+
onlyDeterministic: true,
|
|
19
|
+
zeroUsageOnHit: false
|
|
20
|
+
};
|
|
21
|
+
function readConfig(raw) {
|
|
22
|
+
if (!raw || typeof raw !== "object") return { ...DEFAULTS };
|
|
23
|
+
const r = raw;
|
|
24
|
+
return {
|
|
25
|
+
enabled: r["enabled"] === true,
|
|
26
|
+
maxEntries: typeof r["maxEntries"] === "number" && r["maxEntries"] >= 1 ? Math.floor(r["maxEntries"]) : DEFAULTS.maxEntries,
|
|
27
|
+
ttlMs: typeof r["ttlMs"] === "number" && r["ttlMs"] >= 0 ? r["ttlMs"] : DEFAULTS.ttlMs,
|
|
28
|
+
onlyDeterministic: r["onlyDeterministic"] !== false,
|
|
29
|
+
zeroUsageOnHit: r["zeroUsageOnHit"] === true
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function isDeterministic(request) {
|
|
33
|
+
const t = request["temperature"];
|
|
34
|
+
return t === void 0 || t === 0;
|
|
35
|
+
}
|
|
36
|
+
function fingerprintRequest(request) {
|
|
37
|
+
const subset = {
|
|
38
|
+
model: request["model"] ?? null,
|
|
39
|
+
system: request["system"] ?? null,
|
|
40
|
+
messages: request["messages"] ?? null,
|
|
41
|
+
tools: Array.isArray(request["tools"]) ? request["tools"].map((t) => t?.name ?? t) : null,
|
|
42
|
+
temperature: request["temperature"] ?? null,
|
|
43
|
+
topP: request["topP"] ?? null,
|
|
44
|
+
topK: request["topK"] ?? null,
|
|
45
|
+
maxTokens: request["maxTokens"] ?? null,
|
|
46
|
+
stopSequences: request["stopSequences"] ?? null,
|
|
47
|
+
responseFormat: request["responseFormat"] ?? null,
|
|
48
|
+
reasoning: request["reasoning"] ?? null
|
|
49
|
+
};
|
|
50
|
+
return createHash("sha256").update(JSON.stringify(subset)).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
function lruGet(key, ttlMs) {
|
|
53
|
+
const entry = state.cache.get(key);
|
|
54
|
+
if (!entry) return void 0;
|
|
55
|
+
if (ttlMs > 0 && Date.now() - entry.storedAt > ttlMs) {
|
|
56
|
+
state.cache.delete(key);
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
state.cache.delete(key);
|
|
60
|
+
state.cache.set(key, entry);
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
function lruSet(key, response, maxEntries) {
|
|
64
|
+
state.cache.set(key, { response, storedAt: Date.now(), hits: 0 });
|
|
65
|
+
while (state.cache.size > maxEntries) {
|
|
66
|
+
const oldest = state.cache.keys().next().value;
|
|
67
|
+
if (oldest === void 0) break;
|
|
68
|
+
state.cache.delete(oldest);
|
|
69
|
+
state.evictions += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
var plugin = {
|
|
73
|
+
name: "llm-cache",
|
|
74
|
+
version: "0.1.0",
|
|
75
|
+
description: "Caches identical provider requests and short-circuits the provider call on a hit (wrapProviderRunner). Opt-in; deterministic-only by default.",
|
|
76
|
+
apiVersion: "^0.1.10",
|
|
77
|
+
capabilities: { tools: true },
|
|
78
|
+
defaultConfig: { ...DEFAULTS },
|
|
79
|
+
configSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
enabled: {
|
|
83
|
+
type: "boolean",
|
|
84
|
+
default: false,
|
|
85
|
+
description: "Master switch. OFF by default because caching changes provider call semantics."
|
|
86
|
+
},
|
|
87
|
+
maxEntries: {
|
|
88
|
+
type: "number",
|
|
89
|
+
minimum: 1,
|
|
90
|
+
default: 256,
|
|
91
|
+
description: "LRU capacity; oldest entries are evicted past this."
|
|
92
|
+
},
|
|
93
|
+
ttlMs: {
|
|
94
|
+
type: "number",
|
|
95
|
+
minimum: 0,
|
|
96
|
+
default: 0,
|
|
97
|
+
description: "Entry lifetime in ms. 0 = no expiry."
|
|
98
|
+
},
|
|
99
|
+
onlyDeterministic: {
|
|
100
|
+
type: "boolean",
|
|
101
|
+
default: true,
|
|
102
|
+
description: "Only cache requests with temperature 0 or unset (never freeze a sampled generation)."
|
|
103
|
+
},
|
|
104
|
+
zeroUsageOnHit: {
|
|
105
|
+
type: "boolean",
|
|
106
|
+
default: false,
|
|
107
|
+
description: "Report 0 tokens on a cache hit (true) so cost tracking reflects the saving, or keep the cached usage for context accounting (false)."
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
setup(api) {
|
|
112
|
+
state.cache.clear();
|
|
113
|
+
state.hits = 0;
|
|
114
|
+
state.misses = 0;
|
|
115
|
+
state.skips = 0;
|
|
116
|
+
state.evictions = 0;
|
|
117
|
+
state.savedInputTokens = 0;
|
|
118
|
+
state.savedOutputTokens = 0;
|
|
119
|
+
if (state.extensionUnregister) {
|
|
120
|
+
try {
|
|
121
|
+
state.extensionUnregister();
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
state.extensionUnregister = null;
|
|
125
|
+
}
|
|
126
|
+
const cfg = readConfig(api.config.extensions?.["llm-cache"]);
|
|
127
|
+
if (cfg.enabled) {
|
|
128
|
+
state.extensionUnregister = api.extensions.register({
|
|
129
|
+
name: "llm-cache",
|
|
130
|
+
owner: "llm-cache",
|
|
131
|
+
async wrapProviderRunner(_ctx, request, inner) {
|
|
132
|
+
const req = request ?? {};
|
|
133
|
+
if (cfg.onlyDeterministic && !isDeterministic(req)) {
|
|
134
|
+
state.skips += 1;
|
|
135
|
+
api.metrics.counter("skips");
|
|
136
|
+
return inner(_ctx, request);
|
|
137
|
+
}
|
|
138
|
+
const key = fingerprintRequest(req);
|
|
139
|
+
const cached = lruGet(key, cfg.ttlMs);
|
|
140
|
+
if (cached) {
|
|
141
|
+
cached.hits += 1;
|
|
142
|
+
state.hits += 1;
|
|
143
|
+
state.savedInputTokens += cached.response.usage?.input ?? 0;
|
|
144
|
+
state.savedOutputTokens += cached.response.usage?.output ?? 0;
|
|
145
|
+
api.metrics.counter("hits");
|
|
146
|
+
if (cfg.zeroUsageOnHit) {
|
|
147
|
+
return {
|
|
148
|
+
...cached.response,
|
|
149
|
+
usage: { ...cached.response.usage, input: 0, output: 0 }
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return cached.response;
|
|
153
|
+
}
|
|
154
|
+
state.misses += 1;
|
|
155
|
+
api.metrics.counter("misses");
|
|
156
|
+
const response = await inner(_ctx, request);
|
|
157
|
+
if (response && typeof response === "object" && response.stopReason === "end_turn") {
|
|
158
|
+
lruSet(key, response, cfg.maxEntries);
|
|
159
|
+
}
|
|
160
|
+
return response;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
api.tools.register({
|
|
165
|
+
name: "llm_cache_status",
|
|
166
|
+
description: "Reports llm-cache state: enabled, size, hit/miss/skip counters, and tokens saved by cache hits.",
|
|
167
|
+
inputSchema: { type: "object", properties: {} },
|
|
168
|
+
permission: "auto",
|
|
169
|
+
category: "Diagnostics",
|
|
170
|
+
mutating: false,
|
|
171
|
+
async execute() {
|
|
172
|
+
const total = state.hits + state.misses;
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
enabled: cfg.enabled,
|
|
176
|
+
onlyDeterministic: cfg.onlyDeterministic,
|
|
177
|
+
maxEntries: cfg.maxEntries,
|
|
178
|
+
ttlMs: cfg.ttlMs,
|
|
179
|
+
size: state.cache.size,
|
|
180
|
+
counters: {
|
|
181
|
+
hits: state.hits,
|
|
182
|
+
misses: state.misses,
|
|
183
|
+
skips: state.skips,
|
|
184
|
+
evictions: state.evictions,
|
|
185
|
+
hitRate: total > 0 ? Number((state.hits / total).toFixed(3)) : 0
|
|
186
|
+
},
|
|
187
|
+
saved: { inputTokens: state.savedInputTokens, outputTokens: state.savedOutputTokens }
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
api.tools.register({
|
|
192
|
+
name: "llm_cache_clear",
|
|
193
|
+
description: "Drops all cached provider responses (does not disable the cache).",
|
|
194
|
+
inputSchema: { type: "object", properties: {} },
|
|
195
|
+
permission: "auto",
|
|
196
|
+
category: "Diagnostics",
|
|
197
|
+
mutating: true,
|
|
198
|
+
async execute() {
|
|
199
|
+
const cleared = state.cache.size;
|
|
200
|
+
state.cache.clear();
|
|
201
|
+
return { ok: true, cleared };
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
api.log.info("llm-cache plugin loaded", {
|
|
205
|
+
version: "0.1.0",
|
|
206
|
+
enabled: cfg.enabled,
|
|
207
|
+
onlyDeterministic: cfg.onlyDeterministic,
|
|
208
|
+
maxEntries: cfg.maxEntries
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
teardown(api) {
|
|
212
|
+
if (state.extensionUnregister) {
|
|
213
|
+
try {
|
|
214
|
+
state.extensionUnregister();
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
state.extensionUnregister = null;
|
|
218
|
+
}
|
|
219
|
+
const final = {
|
|
220
|
+
hits: state.hits,
|
|
221
|
+
misses: state.misses,
|
|
222
|
+
skips: state.skips,
|
|
223
|
+
savedInputTokens: state.savedInputTokens,
|
|
224
|
+
savedOutputTokens: state.savedOutputTokens
|
|
225
|
+
};
|
|
226
|
+
state.cache.clear();
|
|
227
|
+
state.hits = 0;
|
|
228
|
+
state.misses = 0;
|
|
229
|
+
state.skips = 0;
|
|
230
|
+
state.evictions = 0;
|
|
231
|
+
state.savedInputTokens = 0;
|
|
232
|
+
state.savedOutputTokens = 0;
|
|
233
|
+
api.log.info("llm-cache: teardown complete", { final });
|
|
234
|
+
},
|
|
235
|
+
async health() {
|
|
236
|
+
const total = state.hits + state.misses;
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
message: `llm-cache: ${state.cache.size} entr(ies), ${state.hits} hit(s) / ${state.misses} miss(es) (${total > 0 ? Math.round(state.hits / total * 100) : 0}% hit rate), ~${state.savedInputTokens + state.savedOutputTokens} tokens saved`,
|
|
240
|
+
counters: {
|
|
241
|
+
hits: state.hits,
|
|
242
|
+
misses: state.misses,
|
|
243
|
+
skips: state.skips,
|
|
244
|
+
evictions: state.evictions
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
var llm_cache_default = plugin;
|
|
250
|
+
|
|
251
|
+
export { llm_cache_default as default, fingerprintRequest, isDeterministic };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Plugin } from '@wrongstack/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* loop-breaker plugin — detects runaway tool-call loops and breaks them.
|
|
5
|
+
*
|
|
6
|
+
* Agents occasionally get stuck re-issuing the same tool call with the
|
|
7
|
+
* same input (a failing bash command retried forever, re-reading the
|
|
8
|
+
* same file, re-writing identical content). Each repeat burns tokens
|
|
9
|
+
* and wall-clock without making progress. This plugin fingerprints
|
|
10
|
+
* every tool call (`toolName` + canonicalized input JSON) via a
|
|
11
|
+
* `PreToolUse '*'` hook and tracks consecutive repeats:
|
|
12
|
+
*
|
|
13
|
+
* - at `warnAfter` repeats → inject `additionalContext` telling the
|
|
14
|
+
* model it is looping and should change approach
|
|
15
|
+
* - at `blockAfter` repeats → block the call outright with a clear
|
|
16
|
+
* reason (unless `mode: 'warn'`)
|
|
17
|
+
*
|
|
18
|
+
* Any *different* call resets the streak, so normal workflows (many
|
|
19
|
+
* distinct reads/edits) are never touched. A small LRU of recent
|
|
20
|
+
* fingerprints also catches A-B-A-B oscillation loops.
|
|
21
|
+
*
|
|
22
|
+
* Config (`config.extensions['loop-breaker']`):
|
|
23
|
+
*
|
|
24
|
+
* ```jsonc
|
|
25
|
+
* {
|
|
26
|
+
* "enabled": true,
|
|
27
|
+
* "mode": "block", // "block" | "warn"
|
|
28
|
+
* "warnAfter": 3, // consecutive identical calls before warning
|
|
29
|
+
* "blockAfter": 5, // consecutive identical calls before blocking
|
|
30
|
+
* "oscillationWindow": 8, // recent-call window for A-B-A-B detection
|
|
31
|
+
* "ignoreTools": [] // tool names exempt from loop detection
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Toggle off entirely with `{ "name": "loop-breaker", "enabled": false }`
|
|
36
|
+
* in `config.plugins`, or `"enabled": false` in the options above.
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
declare const plugin: Plugin;
|
|
42
|
+
|
|
43
|
+
export { plugin as default };
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// src/loop-breaker/index.ts
|
|
2
|
+
var state = {
|
|
3
|
+
lastFingerprint: null,
|
|
4
|
+
streak: 0,
|
|
5
|
+
recent: [],
|
|
6
|
+
invocations: 0,
|
|
7
|
+
warnings: 0,
|
|
8
|
+
blocks: 0,
|
|
9
|
+
oscillationsDetected: 0,
|
|
10
|
+
hookUnregister: null
|
|
11
|
+
};
|
|
12
|
+
var DEFAULTS = {
|
|
13
|
+
enabled: true,
|
|
14
|
+
mode: "block",
|
|
15
|
+
warnAfter: 3,
|
|
16
|
+
blockAfter: 5,
|
|
17
|
+
oscillationWindow: 8,
|
|
18
|
+
ignoreTools: []
|
|
19
|
+
};
|
|
20
|
+
function readConfig(raw) {
|
|
21
|
+
if (!raw || typeof raw !== "object") return { ...DEFAULTS };
|
|
22
|
+
const r = raw;
|
|
23
|
+
const warnAfter = typeof r["warnAfter"] === "number" && r["warnAfter"] >= 2 ? r["warnAfter"] : DEFAULTS.warnAfter;
|
|
24
|
+
const blockAfter = typeof r["blockAfter"] === "number" && r["blockAfter"] > warnAfter ? r["blockAfter"] : Math.max(DEFAULTS.blockAfter, warnAfter + 1);
|
|
25
|
+
return {
|
|
26
|
+
enabled: r["enabled"] !== false,
|
|
27
|
+
mode: r["mode"] === "warn" ? "warn" : "block",
|
|
28
|
+
warnAfter,
|
|
29
|
+
blockAfter,
|
|
30
|
+
oscillationWindow: typeof r["oscillationWindow"] === "number" && r["oscillationWindow"] >= 4 ? r["oscillationWindow"] : DEFAULTS.oscillationWindow,
|
|
31
|
+
ignoreTools: Array.isArray(r["ignoreTools"]) ? r["ignoreTools"].filter((t) => typeof t === "string") : []
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function canonicalize(value) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(sortKeys(value));
|
|
37
|
+
} catch {
|
|
38
|
+
return String(value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function sortKeys(value) {
|
|
42
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
43
|
+
if (value && typeof value === "object") {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const key of Object.keys(value).sort()) {
|
|
46
|
+
out[key] = sortKeys(value[key]);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function fingerprint(toolName, toolInput) {
|
|
53
|
+
return `${toolName}\0${canonicalize(toolInput)}`;
|
|
54
|
+
}
|
|
55
|
+
function isOscillating(recent, windowSize) {
|
|
56
|
+
if (recent.length < windowSize) return false;
|
|
57
|
+
const window = recent.slice(-windowSize);
|
|
58
|
+
const unique = new Set(window);
|
|
59
|
+
if (unique.size !== 2) return false;
|
|
60
|
+
for (let i = 2; i < window.length; i++) {
|
|
61
|
+
if (window[i] !== window[i - 2]) return false;
|
|
62
|
+
}
|
|
63
|
+
return window[0] !== window[1];
|
|
64
|
+
}
|
|
65
|
+
var plugin = {
|
|
66
|
+
name: "loop-breaker",
|
|
67
|
+
version: "0.1.0",
|
|
68
|
+
description: "Detects runaway tool-call loops (identical repeats and A-B-A-B oscillation) \u2014 warns the model, then blocks",
|
|
69
|
+
apiVersion: "^0.1.10",
|
|
70
|
+
capabilities: { tools: true, hooks: true },
|
|
71
|
+
defaultConfig: { ...DEFAULTS },
|
|
72
|
+
configSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
enabled: { type: "boolean", default: true, description: "Master switch." },
|
|
76
|
+
mode: {
|
|
77
|
+
type: "string",
|
|
78
|
+
enum: ["block", "warn"],
|
|
79
|
+
default: "block",
|
|
80
|
+
description: "block = refuse the repeated call; warn = only inject context."
|
|
81
|
+
},
|
|
82
|
+
warnAfter: {
|
|
83
|
+
type: "number",
|
|
84
|
+
minimum: 2,
|
|
85
|
+
default: 3,
|
|
86
|
+
description: "Consecutive identical calls before a warning is injected."
|
|
87
|
+
},
|
|
88
|
+
blockAfter: {
|
|
89
|
+
type: "number",
|
|
90
|
+
minimum: 3,
|
|
91
|
+
default: 5,
|
|
92
|
+
description: "Consecutive identical calls before the call is blocked."
|
|
93
|
+
},
|
|
94
|
+
oscillationWindow: {
|
|
95
|
+
type: "number",
|
|
96
|
+
minimum: 4,
|
|
97
|
+
default: 8,
|
|
98
|
+
description: "Recent-call window length used for A-B-A-B oscillation detection."
|
|
99
|
+
},
|
|
100
|
+
ignoreTools: {
|
|
101
|
+
type: "array",
|
|
102
|
+
items: { type: "string" },
|
|
103
|
+
default: [],
|
|
104
|
+
description: "Tool names exempt from loop detection."
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
setup(api) {
|
|
109
|
+
state.lastFingerprint = null;
|
|
110
|
+
state.streak = 0;
|
|
111
|
+
state.recent = [];
|
|
112
|
+
state.invocations = 0;
|
|
113
|
+
state.warnings = 0;
|
|
114
|
+
state.blocks = 0;
|
|
115
|
+
state.oscillationsDetected = 0;
|
|
116
|
+
if (state.hookUnregister) {
|
|
117
|
+
try {
|
|
118
|
+
state.hookUnregister();
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
state.hookUnregister = null;
|
|
122
|
+
}
|
|
123
|
+
const cfg = readConfig(api.config.extensions?.["loop-breaker"]);
|
|
124
|
+
const hook = (input) => {
|
|
125
|
+
if (!cfg.enabled) return;
|
|
126
|
+
const toolName = input.toolName ?? "unknown";
|
|
127
|
+
if (cfg.ignoreTools.includes(toolName)) return;
|
|
128
|
+
state.invocations += 1;
|
|
129
|
+
const fp = fingerprint(toolName, input.toolInput);
|
|
130
|
+
if (fp === state.lastFingerprint) {
|
|
131
|
+
state.streak += 1;
|
|
132
|
+
} else {
|
|
133
|
+
state.lastFingerprint = fp;
|
|
134
|
+
state.streak = 1;
|
|
135
|
+
}
|
|
136
|
+
state.recent.push(fp);
|
|
137
|
+
if (state.recent.length > Math.max(cfg.oscillationWindow, 16)) {
|
|
138
|
+
state.recent.splice(0, state.recent.length - Math.max(cfg.oscillationWindow, 16));
|
|
139
|
+
}
|
|
140
|
+
if (state.streak >= cfg.blockAfter && cfg.mode === "block") {
|
|
141
|
+
state.blocks += 1;
|
|
142
|
+
api.metrics.counter("blocks");
|
|
143
|
+
return {
|
|
144
|
+
decision: "block",
|
|
145
|
+
reason: `loop-breaker: "${toolName}" has been called ${state.streak} times in a row with identical input. This looks like a runaway loop. Change the input, try a different tool, or explain to the user why repetition is needed. (Disable this guard via config.extensions["loop-breaker"].enabled = false.)`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (state.streak >= cfg.warnAfter) {
|
|
149
|
+
state.warnings += 1;
|
|
150
|
+
api.metrics.counter("warnings");
|
|
151
|
+
const remaining = cfg.mode === "block" ? cfg.blockAfter - state.streak : null;
|
|
152
|
+
return {
|
|
153
|
+
decision: "allow",
|
|
154
|
+
additionalContext: `loop-breaker: "${toolName}" repeated ${state.streak}x with identical input.` + (remaining !== null && remaining > 0 ? ` It will be BLOCKED after ${remaining} more identical call(s).` : "") + " If the previous result was not what you needed, change the approach instead of retrying."
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (isOscillating(state.recent, cfg.oscillationWindow)) {
|
|
158
|
+
state.oscillationsDetected += 1;
|
|
159
|
+
api.metrics.counter("oscillations");
|
|
160
|
+
state.recent = [];
|
|
161
|
+
return {
|
|
162
|
+
decision: "allow",
|
|
163
|
+
additionalContext: `loop-breaker: the last ${cfg.oscillationWindow} tool calls alternate between two identical calls (A-B-A-B pattern). You appear to be undoing and redoing the same work. Step back and pick a single approach.`
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
};
|
|
168
|
+
state.hookUnregister = api.registerHook("PreToolUse", "*", hook);
|
|
169
|
+
api.tools.register({
|
|
170
|
+
name: "loop_breaker_status",
|
|
171
|
+
description: "Reports loop-breaker state: config, current repeat streak, and counters (warnings, blocks, oscillations).",
|
|
172
|
+
inputSchema: { type: "object", properties: {} },
|
|
173
|
+
permission: "auto",
|
|
174
|
+
category: "Diagnostics",
|
|
175
|
+
mutating: false,
|
|
176
|
+
async execute() {
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
enabled: cfg.enabled,
|
|
180
|
+
mode: cfg.mode,
|
|
181
|
+
warnAfter: cfg.warnAfter,
|
|
182
|
+
blockAfter: cfg.blockAfter,
|
|
183
|
+
oscillationWindow: cfg.oscillationWindow,
|
|
184
|
+
ignoreTools: cfg.ignoreTools,
|
|
185
|
+
currentStreak: state.streak,
|
|
186
|
+
counters: {
|
|
187
|
+
invocations: state.invocations,
|
|
188
|
+
warnings: state.warnings,
|
|
189
|
+
blocks: state.blocks,
|
|
190
|
+
oscillationsDetected: state.oscillationsDetected
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
api.log.info("loop-breaker plugin loaded", {
|
|
196
|
+
version: "0.1.0",
|
|
197
|
+
enabled: cfg.enabled,
|
|
198
|
+
mode: cfg.mode,
|
|
199
|
+
warnAfter: cfg.warnAfter,
|
|
200
|
+
blockAfter: cfg.blockAfter
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
teardown(api) {
|
|
204
|
+
if (state.hookUnregister) {
|
|
205
|
+
try {
|
|
206
|
+
state.hookUnregister();
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
state.hookUnregister = null;
|
|
210
|
+
}
|
|
211
|
+
const final = {
|
|
212
|
+
invocations: state.invocations,
|
|
213
|
+
warnings: state.warnings,
|
|
214
|
+
blocks: state.blocks,
|
|
215
|
+
oscillationsDetected: state.oscillationsDetected
|
|
216
|
+
};
|
|
217
|
+
state.lastFingerprint = null;
|
|
218
|
+
state.streak = 0;
|
|
219
|
+
state.recent = [];
|
|
220
|
+
state.invocations = 0;
|
|
221
|
+
state.warnings = 0;
|
|
222
|
+
state.blocks = 0;
|
|
223
|
+
state.oscillationsDetected = 0;
|
|
224
|
+
api.log.info("loop-breaker: teardown complete", { final });
|
|
225
|
+
},
|
|
226
|
+
async health() {
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
message: `loop-breaker: ${state.invocations} call(s) observed, ${state.warnings} warning(s), ${state.blocks} block(s), ${state.oscillationsDetected} oscillation(s)`,
|
|
230
|
+
counters: {
|
|
231
|
+
invocations: state.invocations,
|
|
232
|
+
warnings: state.warnings,
|
|
233
|
+
blocks: state.blocks,
|
|
234
|
+
oscillationsDetected: state.oscillationsDetected
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
var loop_breaker_default = plugin;
|
|
240
|
+
|
|
241
|
+
export { loop_breaker_default as default };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Plugin } from '@wrongstack/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* model-router plugin — routes each provider call to a different model
|
|
5
|
+
* based on declarative rules, on the wire.
|
|
6
|
+
*
|
|
7
|
+
* Like `llm-cache`, this registers an `AgentExtension.wrapProviderRunner`
|
|
8
|
+
* (composed around EVERY provider call in the agent loop). Before the
|
|
9
|
+
* request goes out, the plugin evaluates an ordered rule list against
|
|
10
|
+
* the request's size and shape and rewrites `request.model` to the
|
|
11
|
+
* first matching rule's target — e.g. small/tool-free turns to a cheap
|
|
12
|
+
* fast model, large refactors to a capable one.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT constraint: the active provider instance is fixed for the
|
|
15
|
+
* session. Rerouting only works among models the CURRENT provider
|
|
16
|
+
* serves (e.g. anthropic `haiku` ↔ `opus`). Routing to a model id the
|
|
17
|
+
* provider doesn't recognize will fail at the provider — keep targets
|
|
18
|
+
* within one provider's catalog.
|
|
19
|
+
*
|
|
20
|
+
* Safety posture: opt-in — loads inert until
|
|
21
|
+
* `config.extensions['model-router'].enabled = true`. A `dryRun` mode
|
|
22
|
+
* (default) only records what it WOULD route without changing the model,
|
|
23
|
+
* so you can observe the routing before trusting it.
|
|
24
|
+
*
|
|
25
|
+
* Config (`config.extensions['model-router']`):
|
|
26
|
+
*
|
|
27
|
+
* ```jsonc
|
|
28
|
+
* {
|
|
29
|
+
* "enabled": false,
|
|
30
|
+
* "dryRun": true, // observe only; do not rewrite the model
|
|
31
|
+
* "rules": [
|
|
32
|
+
* { "maxChars": 2000, "hasTools": false, "model": "claude-haiku-4-5" },
|
|
33
|
+
* { "minChars": 40000, "model": "claude-opus-4-8" }
|
|
34
|
+
* ]
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* A rule matches when ALL of its present conditions hold. The first
|
|
39
|
+
* matching rule wins; if none match, the request passes through with its
|
|
40
|
+
* original model.
|
|
41
|
+
*
|
|
42
|
+
* Tools:
|
|
43
|
+
* - `model_router_status` — rules, mode, and per-target routing counts
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
interface RouteRule {
|
|
49
|
+
/** Match when the request's total char size is <= this. */
|
|
50
|
+
maxChars?: number | undefined;
|
|
51
|
+
/** Match when the request's total char size is >= this. */
|
|
52
|
+
minChars?: number | undefined;
|
|
53
|
+
/** Match when the request has (true) / lacks (false) tools. */
|
|
54
|
+
hasTools?: boolean | undefined;
|
|
55
|
+
/** The model id to route matching requests to (required). */
|
|
56
|
+
model: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Total character size of the request's actual prompt text (system +
|
|
60
|
+
* message content). Only counts `text` block values and bare-string
|
|
61
|
+
* content — not structural fields like `type`/`role` — so the size is a
|
|
62
|
+
* meaningful proxy for prompt weight.
|
|
63
|
+
*/
|
|
64
|
+
declare function requestCharSize(request: Record<string, unknown>): number;
|
|
65
|
+
/** First rule whose present conditions all hold, or null. */
|
|
66
|
+
declare function pickRule(rules: RouteRule[], size: number, hasTools: boolean): RouteRule | null;
|
|
67
|
+
declare const plugin: Plugin;
|
|
68
|
+
|
|
69
|
+
export { plugin as default, pickRule, requestCharSize };
|