dsh-autotier 0.1.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/AGENTS.md +93 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +201 -0
- package/README.es.md +247 -0
- package/README.hi.md +241 -0
- package/README.md +245 -0
- package/README.pt.md +246 -0
- package/README.zh.md +221 -0
- package/SECURITY.md +55 -0
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/cordis.patch.yml +125 -0
- package/docs/preset-row.md +61 -0
- package/docs/supporting-lanes.md +45 -0
- package/lib/index.js +2848 -0
- package/lib/types/command.d.ts +17 -0
- package/lib/types/command.d.ts.map +1 -0
- package/lib/types/config.d.ts +94 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/guard-rules.d.ts +97 -0
- package/lib/types/guard-rules.d.ts.map +1 -0
- package/lib/types/guard.d.ts +70 -0
- package/lib/types/guard.d.ts.map +1 -0
- package/lib/types/index.d.ts +60 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/intent.d.ts +179 -0
- package/lib/types/intent.d.ts.map +1 -0
- package/lib/types/judge.d.ts +50 -0
- package/lib/types/judge.d.ts.map +1 -0
- package/lib/types/policy.d.ts +109 -0
- package/lib/types/policy.d.ts.map +1 -0
- package/lib/types/routing.d.ts +135 -0
- package/lib/types/routing.d.ts.map +1 -0
- package/lib/types/schema.d.ts +134 -0
- package/lib/types/schema.d.ts.map +1 -0
- package/lib/types/service.d.ts +67 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +46 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/tiers.d.ts +103 -0
- package/lib/types/tiers.d.ts.map +1 -0
- package/lib/types/tools.d.ts +26 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/types.d.ts +96 -0
- package/lib/types/types.d.ts.map +1 -0
- package/package.json +179 -0
- package/src/command.ts +73 -0
- package/src/config.ts +358 -0
- package/src/guard-rules.ts +303 -0
- package/src/guard.ts +285 -0
- package/src/index.ts +149 -0
- package/src/intent.ts +484 -0
- package/src/judge.ts +150 -0
- package/src/policy.ts +246 -0
- package/src/routing.ts +575 -0
- package/src/schema.ts +295 -0
- package/src/service.ts +131 -0
- package/src/state.ts +134 -0
- package/src/tiers.ts +212 -0
- package/src/tools.ts +128 -0
- package/src/types.ts +120 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2848 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
4
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
|
+
//#region src/tiers.ts
|
|
6
|
+
/** The adapter-owned effort ladder, cheapest to strongest. */
|
|
7
|
+
const EFFORT_LADDER = [
|
|
8
|
+
"off",
|
|
9
|
+
"low",
|
|
10
|
+
"high",
|
|
11
|
+
"max"
|
|
12
|
+
];
|
|
13
|
+
/** Position of one effort on the ladder (0..3); unknown ids rank lowest. */
|
|
14
|
+
function effortRank(effort) {
|
|
15
|
+
const index = EFFORT_LADDER.indexOf(effort);
|
|
16
|
+
return index === -1 ? 0 : index;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The next effort step above `current`, never above `ceiling`.
|
|
20
|
+
* @param current - the effort currently in force.
|
|
21
|
+
* @param ceiling - the strongest effort to consider (default `max`).
|
|
22
|
+
* @returns the next effort id, or null when already at or above the ceiling.
|
|
23
|
+
*/
|
|
24
|
+
function nextEffortStep(current, ceiling = "max") {
|
|
25
|
+
const start = effortRank(current);
|
|
26
|
+
if (start >= effortRank(ceiling)) return null;
|
|
27
|
+
return EFFORT_LADDER[start + 1] ?? null;
|
|
28
|
+
}
|
|
29
|
+
/** Whether two routes land on the same provider/model/effort triple. */
|
|
30
|
+
function routeEquals(a, b) {
|
|
31
|
+
return a.provider === b.provider && a.model === b.model && a.effort === b.effort;
|
|
32
|
+
}
|
|
33
|
+
/** One adapter-owned effort id as the LLM call config expects it (branded at the seam). */
|
|
34
|
+
function brandedEffort(effort) {
|
|
35
|
+
return effort;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Apply one tier route to a request configuration. The sampling scalars the
|
|
39
|
+
* session already chose (`temperature`, `maxTokens`, `stop`) are preserved
|
|
40
|
+
* exactly; the provider/model/effort triple is replaced. Returns the input
|
|
41
|
+
* object unchanged when the route already matches, so the caller can skip a
|
|
42
|
+
* logged header change.
|
|
43
|
+
*
|
|
44
|
+
* @param base - the configuration the loop proposed.
|
|
45
|
+
* @param target - the tier landing to apply.
|
|
46
|
+
* @returns the replacement configuration (or `base` when identical).
|
|
47
|
+
*/
|
|
48
|
+
function resolveRoute(base, target) {
|
|
49
|
+
if (base.provider === target.provider && base.model === target.model && (target.effort === void 0 || base.reasoningEffort === target.effort)) return base;
|
|
50
|
+
const next = {
|
|
51
|
+
provider: target.provider,
|
|
52
|
+
model: target.model
|
|
53
|
+
};
|
|
54
|
+
const effort = target.effort ?? base.reasoningEffort;
|
|
55
|
+
if (effort !== void 0) next.reasoningEffort = brandedEffort(effort);
|
|
56
|
+
if (base.temperature !== void 0) next.temperature = base.temperature;
|
|
57
|
+
if (base.maxTokens !== void 0) next.maxTokens = base.maxTokens;
|
|
58
|
+
if (base.stop !== void 0) next.stop = base.stop;
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the effort-first escalation ladder: raise the current model's effort
|
|
63
|
+
* one step at a time (the KV prefix survives, and the official model-selection
|
|
64
|
+
* notice is not emitted for an effort-only change) before paying for a model
|
|
65
|
+
* switch. When both tiers share one model the ladder collapses to a single
|
|
66
|
+
* effort-only rung.
|
|
67
|
+
*
|
|
68
|
+
* @param base - the configuration the loop proposed for the failing step.
|
|
69
|
+
* @param cheap - the cheap tier landing.
|
|
70
|
+
* @param strong - the strong tier landing.
|
|
71
|
+
* @returns the ordered rungs; empty when escalation cannot change anything.
|
|
72
|
+
*/
|
|
73
|
+
function escalationLadder(base, cheap, strong) {
|
|
74
|
+
const rungs = [];
|
|
75
|
+
const baseEffort = base.reasoningEffort ?? cheap.effort ?? "low";
|
|
76
|
+
const onCheapModel = base.provider === cheap.provider && base.model === cheap.model;
|
|
77
|
+
if (strong.provider === cheap.provider && strong.model === cheap.model) {
|
|
78
|
+
if (strong.effort !== void 0 && strong.effort !== baseEffort) rungs.push({
|
|
79
|
+
route: {
|
|
80
|
+
provider: strong.provider,
|
|
81
|
+
model: strong.model,
|
|
82
|
+
effort: strong.effort
|
|
83
|
+
},
|
|
84
|
+
tier: "strong",
|
|
85
|
+
note: `effort-only escalation on the shared model (${baseEffort} -> ${strong.effort})`
|
|
86
|
+
});
|
|
87
|
+
return rungs;
|
|
88
|
+
}
|
|
89
|
+
if (onCheapModel) {
|
|
90
|
+
let step = nextEffortStep(baseEffort);
|
|
91
|
+
while (step !== null) {
|
|
92
|
+
rungs.push({
|
|
93
|
+
route: {
|
|
94
|
+
provider: cheap.provider,
|
|
95
|
+
model: cheap.model,
|
|
96
|
+
effort: step
|
|
97
|
+
},
|
|
98
|
+
tier: "cheap",
|
|
99
|
+
note: `cheap-tier effort rung ${step}`
|
|
100
|
+
});
|
|
101
|
+
step = nextEffortStep(step);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
rungs.push({
|
|
105
|
+
route: strong.effort === void 0 ? {
|
|
106
|
+
provider: strong.provider,
|
|
107
|
+
model: strong.model
|
|
108
|
+
} : {
|
|
109
|
+
provider: strong.provider,
|
|
110
|
+
model: strong.model,
|
|
111
|
+
effort: strong.effort
|
|
112
|
+
},
|
|
113
|
+
tier: "strong",
|
|
114
|
+
note: "switch to the strong model"
|
|
115
|
+
});
|
|
116
|
+
return rungs;
|
|
117
|
+
}
|
|
118
|
+
/** Failure codes that mean the route itself is unusable: switch the chain now. */
|
|
119
|
+
const FALLBACK_PERMANENT_CODES = [
|
|
120
|
+
"UNKNOWN_MODEL",
|
|
121
|
+
"MISSING_CREDENTIAL",
|
|
122
|
+
"INVALID_CREDENTIAL",
|
|
123
|
+
"QUOTA"
|
|
124
|
+
];
|
|
125
|
+
/** Failure codes owned by `dsh-llm-retry` first: switch the chain only after retries are exhausted. */
|
|
126
|
+
const FALLBACK_TRANSIENT_CODES = [
|
|
127
|
+
"RATE_LIMIT",
|
|
128
|
+
"SERVER",
|
|
129
|
+
"TIMEOUT",
|
|
130
|
+
"TRANSPORT"
|
|
131
|
+
];
|
|
132
|
+
/** Failure codes that must never switch the model. */
|
|
133
|
+
const FALLBACK_IGNORE_CODES = [
|
|
134
|
+
"CONTEXT_WINDOW_EXCEEDED",
|
|
135
|
+
"UNSUPPORTED_REASONING_EFFORT",
|
|
136
|
+
"ABORTED",
|
|
137
|
+
"EMPTY_RESPONSE"
|
|
138
|
+
];
|
|
139
|
+
/**
|
|
140
|
+
* Classify one failed model request for the fallback machinery.
|
|
141
|
+
* @param failure - the normalized failure facts (`code` and optional `status`).
|
|
142
|
+
* @returns the chain verdict.
|
|
143
|
+
*/
|
|
144
|
+
function classifyFallback(failure) {
|
|
145
|
+
if (failure === void 0 || failure === null || typeof failure !== "object") return "unknown";
|
|
146
|
+
const code = typeof failure.code === "string" ? failure.code : void 0;
|
|
147
|
+
if (code !== void 0) {
|
|
148
|
+
if (FALLBACK_PERMANENT_CODES.includes(code)) return "permanent";
|
|
149
|
+
if (FALLBACK_TRANSIENT_CODES.includes(code)) return "transient";
|
|
150
|
+
if (FALLBACK_IGNORE_CODES.includes(code)) return "ignore";
|
|
151
|
+
}
|
|
152
|
+
if (typeof failure.status === "number" && failure.status >= 500) return "transient";
|
|
153
|
+
return "unknown";
|
|
154
|
+
}
|
|
155
|
+
/** Whether a fallback record is currently pinning the agent to a chain entry. */
|
|
156
|
+
function fallbackActive(record, now) {
|
|
157
|
+
return record !== void 0 && record.index >= 0 && record.until > now;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Advance a fallback record one step down one tier's chain.
|
|
161
|
+
* @param record - the current record (absent or from another tier = start of this chain).
|
|
162
|
+
* @param tier - the tier whose chain is being walked.
|
|
163
|
+
* @param chainLength - the number of configured fallback entries.
|
|
164
|
+
* @param now - current epoch millis.
|
|
165
|
+
* @param ttlMs - how long the new entry stays in force.
|
|
166
|
+
* @returns the next record, or null when the chain is exhausted.
|
|
167
|
+
*/
|
|
168
|
+
function advanceFallback(record, tier, chainLength, now, ttlMs) {
|
|
169
|
+
const next = (record !== void 0 && record.tier === tier ? record.index : -1) + 1;
|
|
170
|
+
if (next >= chainLength) return null;
|
|
171
|
+
return {
|
|
172
|
+
tier,
|
|
173
|
+
index: next,
|
|
174
|
+
until: now + (Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 3e5)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/policy.ts
|
|
179
|
+
/** A fresh per-agent state. */
|
|
180
|
+
function createRouteState() {
|
|
181
|
+
return {
|
|
182
|
+
decision: void 0,
|
|
183
|
+
input: void 0,
|
|
184
|
+
override: void 0,
|
|
185
|
+
appliedTier: void 0,
|
|
186
|
+
appliedSource: void 0,
|
|
187
|
+
planActive: false,
|
|
188
|
+
escalation: void 0,
|
|
189
|
+
fallback: void 0,
|
|
190
|
+
judge: {
|
|
191
|
+
failures: 0,
|
|
192
|
+
lastCall: 0
|
|
193
|
+
},
|
|
194
|
+
verified: false,
|
|
195
|
+
reviewOwedFor: void 0,
|
|
196
|
+
probe: void 0,
|
|
197
|
+
denials: 0,
|
|
198
|
+
lastDenial: ""
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Whether the current failure escalation is still in force. */
|
|
202
|
+
function escalationActive(state, now) {
|
|
203
|
+
return state.escalation !== void 0 && state.escalation.until > now;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Apply the double-threshold hysteresis to a classifier-driven change. The
|
|
207
|
+
* anchor is only honoured when the applied tier itself came from the
|
|
208
|
+
* classifier: an escalation, plan-mode, rule or manual decision is a deliberate
|
|
209
|
+
* instruction, so returning from it must not be damped (otherwise a session
|
|
210
|
+
* that escalated once never returns to the cheap tier).
|
|
211
|
+
*/
|
|
212
|
+
function withHysteresis(state, proposed, confidence, config) {
|
|
213
|
+
const applied = state.appliedTier;
|
|
214
|
+
if (applied === void 0 || applied === proposed) return proposed;
|
|
215
|
+
const anchorSource = state.appliedSource;
|
|
216
|
+
if (anchorSource !== "judge" && anchorSource !== "posterior" && anchorSource !== "default") return proposed;
|
|
217
|
+
const { toStrong, toCheap } = config.intent.hysteresis;
|
|
218
|
+
if (proposed === "strong" && confidence < toStrong) return applied;
|
|
219
|
+
if (proposed === "cheap" && confidence >= toCheap) return applied;
|
|
220
|
+
return proposed;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the tier for the current step.
|
|
224
|
+
*
|
|
225
|
+
* Precedence (highest first): explicit override, active failure escalation,
|
|
226
|
+
* plan mode, declarative rule, fingerprint posterior, classifier verdict.
|
|
227
|
+
* Hysteresis applies only to the classifier verdict, so an explicit override
|
|
228
|
+
* or an escalation takes effect immediately.
|
|
229
|
+
*
|
|
230
|
+
* @param input - the live state and the classification.
|
|
231
|
+
* @returns the decision with its provenance.
|
|
232
|
+
*/
|
|
233
|
+
function decideTier(input) {
|
|
234
|
+
const { state, config, intent, rule, override, now } = input;
|
|
235
|
+
if (override === "strong" || override === "cheap") return {
|
|
236
|
+
tier: override,
|
|
237
|
+
source: "manual",
|
|
238
|
+
reason: `/tier ${override}`,
|
|
239
|
+
confidence: 1
|
|
240
|
+
};
|
|
241
|
+
if (escalationActive(state, now)) return {
|
|
242
|
+
tier: "strong",
|
|
243
|
+
source: "escalation",
|
|
244
|
+
reason: `escalated after ${String(state.escalation?.count ?? 0)} failure(s)`,
|
|
245
|
+
confidence: 1
|
|
246
|
+
};
|
|
247
|
+
if (state.planActive) return {
|
|
248
|
+
tier: "strong",
|
|
249
|
+
source: "plan-mode",
|
|
250
|
+
reason: "plan mode is active",
|
|
251
|
+
confidence: 1
|
|
252
|
+
};
|
|
253
|
+
if (fallbackActive(state.fallback, now)) return {
|
|
254
|
+
tier: intent.tier,
|
|
255
|
+
source: "fallback",
|
|
256
|
+
reason: `fallback chain entry ${String((state.fallback?.index ?? 0) + 1)}`,
|
|
257
|
+
confidence: intent.confidence
|
|
258
|
+
};
|
|
259
|
+
if (rule !== null) return {
|
|
260
|
+
tier: rule.tier,
|
|
261
|
+
source: "rule",
|
|
262
|
+
reason: `rule ${rule.id}`,
|
|
263
|
+
confidence: 1
|
|
264
|
+
};
|
|
265
|
+
const posterior = state.probe ?? null;
|
|
266
|
+
if (posterior !== null) return {
|
|
267
|
+
tier: posterior,
|
|
268
|
+
source: "posterior",
|
|
269
|
+
reason: `fingerprint ${intent.fingerprint} posterior`,
|
|
270
|
+
confidence: 1
|
|
271
|
+
};
|
|
272
|
+
const tier = withHysteresis(state, intent.tier, intent.confidence, config);
|
|
273
|
+
return {
|
|
274
|
+
tier,
|
|
275
|
+
source: intent.shortCircuit !== void 0 ? "rule" : "judge",
|
|
276
|
+
reason: tier === intent.tier ? intent.reasons.join("; ") : `hysteresis kept ${tier} (proposed ${intent.tier} at confidence ${intent.confidence.toFixed(2)})`,
|
|
277
|
+
confidence: intent.confidence
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Whether the low-confidence judge should be consulted for this input. */
|
|
281
|
+
function judgeNeeded(config, state, intent, rule, now) {
|
|
282
|
+
if (!config.intent.judge.enabled) return false;
|
|
283
|
+
if (rule !== null) return false;
|
|
284
|
+
if (intent.shortCircuit !== void 0) return false;
|
|
285
|
+
if (intent.confidence >= config.intent.ruleThreshold) return false;
|
|
286
|
+
if (state.judge.failures >= config.intent.judge.unavailableSkip) return false;
|
|
287
|
+
return now - state.judge.lastCall >= config.intent.judge.cooldownMs;
|
|
288
|
+
}
|
|
289
|
+
/** Whether the middle band should start cheap and verify on a signal. */
|
|
290
|
+
function attemptBandApplies(config, intent) {
|
|
291
|
+
if (!config.intent.attemptBand.enabled) return false;
|
|
292
|
+
if (intent.shortCircuit !== void 0) return false;
|
|
293
|
+
return intent.confidence >= config.intent.attemptBand.tauLow && intent.confidence < config.intent.ruleThreshold;
|
|
294
|
+
}
|
|
295
|
+
/** Record one judge call attempt. */
|
|
296
|
+
function noteJudgeCall(state, now, ok) {
|
|
297
|
+
state.judge.lastCall = now;
|
|
298
|
+
state.judge.failures = ok ? 0 : state.judge.failures + 1;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Record one failure against the escalation counter.
|
|
302
|
+
* @param state - the agent's state.
|
|
303
|
+
* @param signature - the failure signature (`code|fingerprint`); only identical
|
|
304
|
+
* signatures accumulate when `escalation.signature` is enabled.
|
|
305
|
+
* @param config - the resolved configuration.
|
|
306
|
+
* @param now - current epoch millis.
|
|
307
|
+
* @returns whether this failure escalated the tier.
|
|
308
|
+
*/
|
|
309
|
+
function noteFailure(state, signature, config, now) {
|
|
310
|
+
const current = state.escalation;
|
|
311
|
+
const sameSignature = config.escalation.signature ? current?.signature === signature : true;
|
|
312
|
+
const withinWindow = current !== void 0 && now - current.lastAt <= config.escalation.windowMs;
|
|
313
|
+
const count = sameSignature && current !== void 0 && withinWindow ? current.count + 1 : 1;
|
|
314
|
+
const escalated = count >= config.escalation.threshold;
|
|
315
|
+
state.escalation = {
|
|
316
|
+
count,
|
|
317
|
+
signature: config.escalation.signature ? signature : "",
|
|
318
|
+
until: escalated ? now + config.escalation.ttlMs : current?.until ?? 0,
|
|
319
|
+
rung: escalated ? (current?.rung ?? 0) + 1 : current?.rung ?? 0,
|
|
320
|
+
lastAt: now
|
|
321
|
+
};
|
|
322
|
+
return escalated;
|
|
323
|
+
}
|
|
324
|
+
/** Clear an expired escalation lazily. A record that never escalated keeps its window count. */
|
|
325
|
+
function clearExpiredEscalation(state, now) {
|
|
326
|
+
if (state.escalation !== void 0 && state.escalation.until > 0 && state.escalation.until <= now) state.escalation = void 0;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Advance the agent's fallback chain for one tier after an unusable route.
|
|
330
|
+
* @returns whether a chain entry was taken (false = chain exhausted).
|
|
331
|
+
*/
|
|
332
|
+
function noteFallback(state, tier, chainLength, config, now) {
|
|
333
|
+
const next = advanceFallback(state.fallback, tier, chainLength, now, config.escalation.fallbackTtlMs);
|
|
334
|
+
if (next === null) {
|
|
335
|
+
if (state.fallback?.tier === tier) state.fallback = void 0;
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
state.fallback = next;
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/types.ts
|
|
343
|
+
/**
|
|
344
|
+
* Shared vocabulary for dsh-autotier: the tier ids, the adapter-owned reasoning
|
|
345
|
+
* effort ids, the routing modes, and the public route/status shapes every
|
|
346
|
+
* module and the `ctx.autotier` service speak.
|
|
347
|
+
* @module dsh-autotier/types
|
|
348
|
+
*/
|
|
349
|
+
/**
|
|
350
|
+
* Service Definition (contract layer): the two cost tiers this plugin routes
|
|
351
|
+
* between. `strong` plans complex intent and reviews high-risk work; `cheap`
|
|
352
|
+
* implements it. The ids are stable wire/settings vocabulary.
|
|
353
|
+
*/
|
|
354
|
+
const TIER_IDS = ["strong", "cheap"];
|
|
355
|
+
/**
|
|
356
|
+
* Reasoning-effort ids owned by the provider adapter. The DeepSeek adapter
|
|
357
|
+
* accepts exactly these four (`packages/llm/llm-deepseek/src/index.ts:133`);
|
|
358
|
+
* `medium` does not exist, and an unsupported value fails every request with
|
|
359
|
+
* `UNSUPPORTED_REASONING_EFFORT` instead of degrading.
|
|
360
|
+
*/
|
|
361
|
+
const EFFORT_IDS = [
|
|
362
|
+
"off",
|
|
363
|
+
"low",
|
|
364
|
+
"high",
|
|
365
|
+
"max"
|
|
366
|
+
];
|
|
367
|
+
/**
|
|
368
|
+
* Routing modes. `auto` is the fully automatic path; the rest are explicit
|
|
369
|
+
* user overrides (`/tier strong|cheap|off`). `delegated` means the session
|
|
370
|
+
* carries an explicit model selection that autotier must not fight.
|
|
371
|
+
*/
|
|
372
|
+
const ROUTING_MODES = [
|
|
373
|
+
"auto",
|
|
374
|
+
"strong",
|
|
375
|
+
"cheap",
|
|
376
|
+
"delegated",
|
|
377
|
+
"off"
|
|
378
|
+
];
|
|
379
|
+
/** Intent classes the rule layer and the judge distinguish. */
|
|
380
|
+
const SCENARIOS = [
|
|
381
|
+
"coding",
|
|
382
|
+
"review",
|
|
383
|
+
"planning",
|
|
384
|
+
"retrieval",
|
|
385
|
+
"batch",
|
|
386
|
+
"daily",
|
|
387
|
+
"longText",
|
|
388
|
+
"multimodal"
|
|
389
|
+
];
|
|
390
|
+
/** Cost/quality arbitration direction when the signals are ambiguous. */
|
|
391
|
+
const COST_MODES = [
|
|
392
|
+
"cost-first",
|
|
393
|
+
"quality-first",
|
|
394
|
+
"balanced"
|
|
395
|
+
];
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/command.ts
|
|
398
|
+
/** Format the tier table for `/tier status`. */
|
|
399
|
+
function statusText(service, state) {
|
|
400
|
+
const status = service.status();
|
|
401
|
+
const strong = status.tiers.strong;
|
|
402
|
+
const cheap = status.tiers.cheap;
|
|
403
|
+
const lines = [
|
|
404
|
+
`dsh-autotier — mode: ${state.override ?? status.mode}`,
|
|
405
|
+
`strong: ${strong.provider}/${strong.model}${strong.effort === void 0 ? " (follows session effort)" : ` @${strong.effort}`}`,
|
|
406
|
+
`cheap: ${cheap.provider}/${cheap.model}${cheap.effort === void 0 ? " (follows session effort)" : ` @${cheap.effort}`}`,
|
|
407
|
+
`vision: ${status.tiers.vision.provider}/${status.tiers.vision.model}`,
|
|
408
|
+
`guard: ${status.guard.enabled ? "on" : "off"} (${status.guard.tiers.join(", ")})`
|
|
409
|
+
];
|
|
410
|
+
const decision = state.decision;
|
|
411
|
+
if (decision !== void 0) {
|
|
412
|
+
lines.push(`last intent: ${decision.scenario} (confidence ${decision.confidence.toFixed(2)}, fingerprint ${decision.fingerprint})`);
|
|
413
|
+
if (decision.reasons.length > 0) lines.push(` because: ${decision.reasons.join("; ")}`);
|
|
414
|
+
}
|
|
415
|
+
if (state.appliedTier !== void 0) lines.push(`applied tier: ${state.appliedTier}`);
|
|
416
|
+
if (escalationActive(state, Date.now())) lines.push(`escalation: active for ${String(Math.ceil(((state.escalation?.until ?? 0) - Date.now()) / 1e3))}s`);
|
|
417
|
+
if (state.fallback !== void 0) lines.push(`fallback: chain entry ${String(state.fallback.index + 1)}`);
|
|
418
|
+
if (state.planActive) lines.push("plan mode: active (strong tier)");
|
|
419
|
+
if (state.denials > 0) lines.push(`guard denials: ${String(state.denials)} (last rule ${state.lastDenial})`);
|
|
420
|
+
return lines.join("\n");
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Register the `/tier` command on the plugin fiber.
|
|
424
|
+
* @param ctx - the plugin context (must have `commands`).
|
|
425
|
+
* @param service - the live service.
|
|
426
|
+
* @param states - the per-agent state store.
|
|
427
|
+
*/
|
|
428
|
+
function registerTierCommand(ctx, service, states) {
|
|
429
|
+
ctx.commands.register({
|
|
430
|
+
name: "tier",
|
|
431
|
+
description: "Show or set this session's model tier (auto | strong | cheap | delegated | off | status).",
|
|
432
|
+
input: { hint: "auto | strong | cheap | delegated | off | status" },
|
|
433
|
+
handler: ({ agent, rawInput }) => {
|
|
434
|
+
const argument = rawInput.trim().toLowerCase();
|
|
435
|
+
const state = states.for(agent);
|
|
436
|
+
if (argument === "" || argument === "status") return {
|
|
437
|
+
kind: "success",
|
|
438
|
+
text: statusText(service, state)
|
|
439
|
+
};
|
|
440
|
+
const mode = ROUTING_MODES.find((candidate) => candidate === argument);
|
|
441
|
+
if (mode === void 0) return {
|
|
442
|
+
kind: "error",
|
|
443
|
+
text: `unknown tier mode ${JSON.stringify(argument)}; use auto | strong | cheap | off | status`
|
|
444
|
+
};
|
|
445
|
+
state.override = mode === "auto" ? void 0 : mode;
|
|
446
|
+
const status = service.status();
|
|
447
|
+
return {
|
|
448
|
+
kind: "success",
|
|
449
|
+
text: `dsh-autotier: session mode set to ${state.override ?? status.mode}\n${statusText(service, state)}`
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/schema.ts
|
|
456
|
+
/**
|
|
457
|
+
* The raw (possibly partial) configuration surface of dsh-autotier: the
|
|
458
|
+
* Schemastery schema the Loader validates and the settings UI renders, plus the
|
|
459
|
+
* interfaces it resolves to. The judgement that turns a raw config into a
|
|
460
|
+
* resolved one lives in `config.ts`, so this module stays free of executable
|
|
461
|
+
* logic (a schema module must not mix function values into its declarations).
|
|
462
|
+
*
|
|
463
|
+
* @module dsh-autotier/schema
|
|
464
|
+
*/
|
|
465
|
+
/** The default strong tier: the catalog's quality-critical model at high effort. */
|
|
466
|
+
const DEFAULT_STRONG = {
|
|
467
|
+
provider: "deepseek-official",
|
|
468
|
+
model: "deepseek-v4-pro",
|
|
469
|
+
effort: "high",
|
|
470
|
+
followSession: false
|
|
471
|
+
};
|
|
472
|
+
/** The default cheap tier: the catalog's routine/parallel model at low effort. */
|
|
473
|
+
const DEFAULT_CHEAP = {
|
|
474
|
+
provider: "deepseek-official",
|
|
475
|
+
model: "deepseek-v4-flash",
|
|
476
|
+
effort: "low",
|
|
477
|
+
followSession: true
|
|
478
|
+
};
|
|
479
|
+
/** The default vision landing: the catalog's only image-capable model. */
|
|
480
|
+
const DEFAULT_VISION = {
|
|
481
|
+
provider: "deepseek-official",
|
|
482
|
+
model: "deepseek-v4-flash-vision-exp"
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* One tier schema per tier, so a partially-specified tier gets the same
|
|
486
|
+
* per-field defaults as the whole-object default (a shared schema would make
|
|
487
|
+
* `Config({ tiers: { cheap: { model } } })` disagree with `resolveConfig` on
|
|
488
|
+
* `followSession` and `effort`).
|
|
489
|
+
*/
|
|
490
|
+
const strongTier = z.object({
|
|
491
|
+
provider: z.string().default(DEFAULT_STRONG.provider),
|
|
492
|
+
model: z.string().default(DEFAULT_STRONG.model),
|
|
493
|
+
effort: z.union([...EFFORT_IDS]).default(DEFAULT_STRONG.effort),
|
|
494
|
+
followSession: z.boolean().default(DEFAULT_STRONG.followSession),
|
|
495
|
+
fallback: z.array(z.object({
|
|
496
|
+
provider: z.string().default("deepseek-official"),
|
|
497
|
+
model: z.string().default("")
|
|
498
|
+
})).default([])
|
|
499
|
+
});
|
|
500
|
+
const cheapTier = z.object({
|
|
501
|
+
provider: z.string().default(DEFAULT_CHEAP.provider),
|
|
502
|
+
model: z.string().default(DEFAULT_CHEAP.model),
|
|
503
|
+
effort: z.union([...EFFORT_IDS]).default(DEFAULT_CHEAP.effort),
|
|
504
|
+
followSession: z.boolean().default(DEFAULT_CHEAP.followSession),
|
|
505
|
+
fallback: z.array(z.object({
|
|
506
|
+
provider: z.string().default("deepseek-official"),
|
|
507
|
+
model: z.string().default("")
|
|
508
|
+
})).default([])
|
|
509
|
+
});
|
|
510
|
+
/** Schemastery schema: the loader validates and fills defaults before `apply`. */
|
|
511
|
+
const Config = z.object({
|
|
512
|
+
tiers: z.object({
|
|
513
|
+
strong: strongTier.default({
|
|
514
|
+
...DEFAULT_STRONG,
|
|
515
|
+
fallback: []
|
|
516
|
+
}),
|
|
517
|
+
cheap: cheapTier.default({
|
|
518
|
+
...DEFAULT_CHEAP,
|
|
519
|
+
fallback: []
|
|
520
|
+
}),
|
|
521
|
+
vision: z.object({
|
|
522
|
+
provider: z.string().default("deepseek-official"),
|
|
523
|
+
model: z.string().default("deepseek-v4-flash-vision-exp")
|
|
524
|
+
}).default({ ...DEFAULT_VISION })
|
|
525
|
+
}).default({
|
|
526
|
+
strong: {
|
|
527
|
+
...DEFAULT_STRONG,
|
|
528
|
+
fallback: []
|
|
529
|
+
},
|
|
530
|
+
cheap: {
|
|
531
|
+
...DEFAULT_CHEAP,
|
|
532
|
+
fallback: []
|
|
533
|
+
},
|
|
534
|
+
vision: { ...DEFAULT_VISION }
|
|
535
|
+
}),
|
|
536
|
+
intent: z.object({
|
|
537
|
+
ruleThreshold: z.number().min(1e-6).max(1).default(.7),
|
|
538
|
+
attemptBand: z.object({
|
|
539
|
+
enabled: z.boolean().default(false),
|
|
540
|
+
tauLow: z.number().min(0).max(1).default(.45)
|
|
541
|
+
}).default({
|
|
542
|
+
enabled: false,
|
|
543
|
+
tauLow: .45
|
|
544
|
+
}),
|
|
545
|
+
hysteresis: z.object({
|
|
546
|
+
toStrong: z.number().min(0).max(1).default(.8),
|
|
547
|
+
toCheap: z.number().min(0).max(1).default(.6)
|
|
548
|
+
}).default({
|
|
549
|
+
toStrong: .8,
|
|
550
|
+
toCheap: .6
|
|
551
|
+
}),
|
|
552
|
+
rules: z.array(z.object({
|
|
553
|
+
id: z.string().default(""),
|
|
554
|
+
when: z.object({
|
|
555
|
+
patterns: z.array(z.string()).default([]),
|
|
556
|
+
tools: z.array(z.string()).default([]),
|
|
557
|
+
cwd: z.string().default("")
|
|
558
|
+
}).default({
|
|
559
|
+
patterns: [],
|
|
560
|
+
tools: [],
|
|
561
|
+
cwd: ""
|
|
562
|
+
}),
|
|
563
|
+
tier: z.union(["cheap", "strong"]).default("strong"),
|
|
564
|
+
priority: z.number().default(0)
|
|
565
|
+
})).default([]),
|
|
566
|
+
judge: z.object({
|
|
567
|
+
enabled: z.boolean().default(true),
|
|
568
|
+
model: z.string().default(""),
|
|
569
|
+
temperature: z.number().min(0).max(2).default(0),
|
|
570
|
+
maxTokens: z.number().step(1).min(1).max(4096).default(16),
|
|
571
|
+
cooldownMs: z.number().min(0).max(36e5).default(3e4),
|
|
572
|
+
timeoutMs: z.number().min(1).max(12e4).default(2e3),
|
|
573
|
+
unavailableSkip: z.number().step(1).min(0).max(100).default(2)
|
|
574
|
+
}).default({
|
|
575
|
+
enabled: true,
|
|
576
|
+
model: "",
|
|
577
|
+
temperature: 0,
|
|
578
|
+
maxTokens: 16,
|
|
579
|
+
cooldownMs: 3e4,
|
|
580
|
+
timeoutMs: 2e3,
|
|
581
|
+
unavailableSkip: 2
|
|
582
|
+
}),
|
|
583
|
+
scenarios: z.object({
|
|
584
|
+
coding: z.boolean().default(true),
|
|
585
|
+
review: z.boolean().default(true),
|
|
586
|
+
planning: z.boolean().default(true),
|
|
587
|
+
retrieval: z.boolean().default(true),
|
|
588
|
+
batch: z.boolean().default(true),
|
|
589
|
+
daily: z.boolean().default(true),
|
|
590
|
+
longText: z.boolean().default(true),
|
|
591
|
+
multimodal: z.boolean().default(true)
|
|
592
|
+
}).default({
|
|
593
|
+
coding: true,
|
|
594
|
+
review: true,
|
|
595
|
+
planning: true,
|
|
596
|
+
retrieval: true,
|
|
597
|
+
batch: true,
|
|
598
|
+
daily: true,
|
|
599
|
+
longText: true,
|
|
600
|
+
multimodal: true
|
|
601
|
+
}),
|
|
602
|
+
costMode: z.union([...COST_MODES]).default("balanced")
|
|
603
|
+
}).default({
|
|
604
|
+
ruleThreshold: .7,
|
|
605
|
+
attemptBand: {
|
|
606
|
+
enabled: false,
|
|
607
|
+
tauLow: .45
|
|
608
|
+
},
|
|
609
|
+
hysteresis: {
|
|
610
|
+
toStrong: .8,
|
|
611
|
+
toCheap: .6
|
|
612
|
+
},
|
|
613
|
+
rules: [],
|
|
614
|
+
judge: {
|
|
615
|
+
enabled: true,
|
|
616
|
+
model: "",
|
|
617
|
+
temperature: 0,
|
|
618
|
+
maxTokens: 16,
|
|
619
|
+
cooldownMs: 3e4,
|
|
620
|
+
timeoutMs: 2e3,
|
|
621
|
+
unavailableSkip: 2
|
|
622
|
+
},
|
|
623
|
+
scenarios: {
|
|
624
|
+
coding: true,
|
|
625
|
+
review: true,
|
|
626
|
+
planning: true,
|
|
627
|
+
retrieval: true,
|
|
628
|
+
batch: true,
|
|
629
|
+
daily: true,
|
|
630
|
+
longText: true,
|
|
631
|
+
multimodal: true
|
|
632
|
+
},
|
|
633
|
+
costMode: "balanced"
|
|
634
|
+
}),
|
|
635
|
+
guard: z.object({
|
|
636
|
+
enabled: z.boolean().default(true),
|
|
637
|
+
tiers: z.array(z.union(["cheap"])).default(["cheap"]),
|
|
638
|
+
whitelist: z.array(z.string()).default([]),
|
|
639
|
+
protectedPaths: z.array(z.string()).default([
|
|
640
|
+
".dsh",
|
|
641
|
+
"AGENTS.md",
|
|
642
|
+
"package.json",
|
|
643
|
+
".github/workflows"
|
|
644
|
+
]),
|
|
645
|
+
interopDefend: z.union(["auto", "none"]).default("auto")
|
|
646
|
+
}).default({
|
|
647
|
+
enabled: true,
|
|
648
|
+
tiers: ["cheap"],
|
|
649
|
+
whitelist: [],
|
|
650
|
+
protectedPaths: [
|
|
651
|
+
".dsh",
|
|
652
|
+
"AGENTS.md",
|
|
653
|
+
"package.json",
|
|
654
|
+
".github/workflows"
|
|
655
|
+
],
|
|
656
|
+
interopDefend: "auto"
|
|
657
|
+
}),
|
|
658
|
+
escalation: z.object({
|
|
659
|
+
threshold: z.number().step(1).min(1).max(100).default(2),
|
|
660
|
+
windowMs: z.number().min(1).max(864e5).default(6e4),
|
|
661
|
+
ttlMs: z.number().min(1).max(864e5).default(18e4),
|
|
662
|
+
fallbackTtlMs: z.number().min(1).max(864e5).default(3e5),
|
|
663
|
+
signature: z.boolean().default(true)
|
|
664
|
+
}).default({
|
|
665
|
+
threshold: 2,
|
|
666
|
+
windowMs: 6e4,
|
|
667
|
+
ttlMs: 18e4,
|
|
668
|
+
fallbackTtlMs: 3e5,
|
|
669
|
+
signature: true
|
|
670
|
+
}),
|
|
671
|
+
routingMode: z.union([...ROUTING_MODES]).default("auto")
|
|
672
|
+
});
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/config.ts
|
|
675
|
+
/**
|
|
676
|
+
* The explicit-resolve judge for dsh-autotier: it re-checks every default,
|
|
677
|
+
* bound and cross-field requirement field by field, so programmatic
|
|
678
|
+
* construction that bypasses Schemastery normalization still fails loud. The
|
|
679
|
+
* schema itself lives in `schema.ts`.
|
|
680
|
+
*
|
|
681
|
+
* @module dsh-autotier/config
|
|
682
|
+
*/
|
|
683
|
+
/** Throw the standard fail-loud config error for one invalid field. */
|
|
684
|
+
function invalid(field, detail) {
|
|
685
|
+
throw new Error(`dsh-autotier: config.${field} ${detail}`);
|
|
686
|
+
}
|
|
687
|
+
/** Read a required non-empty string field, failing loud when absent or blank. */
|
|
688
|
+
function text(field, value, fallback) {
|
|
689
|
+
const resolved = value ?? fallback;
|
|
690
|
+
if (typeof resolved !== "string" || resolved.trim().length === 0) invalid(field, "must be a non-empty string");
|
|
691
|
+
return resolved;
|
|
692
|
+
}
|
|
693
|
+
/** Read a bounded finite number. */
|
|
694
|
+
function number(field, value, fallback, min, max) {
|
|
695
|
+
const resolved = value ?? fallback;
|
|
696
|
+
if (!Number.isFinite(resolved) || resolved < min || resolved > max) invalid(field, `must be a finite number in [${String(min)}, ${String(max)}]`);
|
|
697
|
+
return resolved;
|
|
698
|
+
}
|
|
699
|
+
/** Read an integer in a closed range. */
|
|
700
|
+
function integer(field, value, fallback, min, max) {
|
|
701
|
+
const resolved = value ?? fallback;
|
|
702
|
+
if (!Number.isInteger(resolved) || resolved < min || resolved > max) invalid(field, `must be an integer in [${String(min)}, ${String(max)}]`);
|
|
703
|
+
return resolved;
|
|
704
|
+
}
|
|
705
|
+
/** Read a boolean switch. */
|
|
706
|
+
function boolean(field, value, fallback) {
|
|
707
|
+
const resolved = value ?? fallback;
|
|
708
|
+
if (typeof resolved !== "boolean") invalid(field, "must be a boolean");
|
|
709
|
+
return resolved;
|
|
710
|
+
}
|
|
711
|
+
/** Read one member of a closed string set. */
|
|
712
|
+
function member(field, value, fallback, allowed) {
|
|
713
|
+
const resolved = value ?? fallback;
|
|
714
|
+
if (!allowed.includes(resolved)) invalid(field, `must be one of ${allowed.join(", ")}`);
|
|
715
|
+
return resolved;
|
|
716
|
+
}
|
|
717
|
+
/** Resolve one tier, judging its landing, effort vocabulary and fallback chain. */
|
|
718
|
+
function resolveTier(tier, raw, fallback) {
|
|
719
|
+
const provider = text(`tiers.${tier}.provider`, raw?.provider, fallback.provider);
|
|
720
|
+
const model = text(`tiers.${tier}.model`, raw?.model, fallback.model);
|
|
721
|
+
const effort = member(`tiers.${tier}.effort`, raw?.effort, fallback.effort, EFFORT_IDS);
|
|
722
|
+
const followSession = boolean(`tiers.${tier}.followSession`, raw?.followSession, fallback.followSession);
|
|
723
|
+
const chain = [];
|
|
724
|
+
const seen = /* @__PURE__ */ new Set([`${provider}/${model}`]);
|
|
725
|
+
for (const [index, entry] of (raw?.fallback ?? []).entries()) {
|
|
726
|
+
const entryProvider = text(`tiers.${tier}.fallback[${String(index)}].provider`, entry.provider, "");
|
|
727
|
+
const entryModel = text(`tiers.${tier}.fallback[${String(index)}].model`, entry.model, "");
|
|
728
|
+
const key = `${entryProvider}/${entryModel}`;
|
|
729
|
+
if (seen.has(key)) invalid(`tiers.${tier}.fallback[${String(index)}]`, `duplicates the tier landing or an earlier fallback (${key})`);
|
|
730
|
+
seen.add(key);
|
|
731
|
+
chain.push({
|
|
732
|
+
provider: entryProvider,
|
|
733
|
+
model: entryModel
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
provider,
|
|
738
|
+
model,
|
|
739
|
+
effort,
|
|
740
|
+
followSession,
|
|
741
|
+
fallback: chain
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/** Resolve the intent section. */
|
|
745
|
+
function resolveIntent(raw) {
|
|
746
|
+
const intent = raw ?? {};
|
|
747
|
+
const ruleThreshold = number("intent.ruleThreshold", intent.ruleThreshold, .7, 1e-6, 1);
|
|
748
|
+
const tauLow = number("intent.attemptBand.tauLow", intent.attemptBand?.tauLow, .45, 0, 1);
|
|
749
|
+
if (tauLow >= ruleThreshold) invalid("intent.attemptBand.tauLow", `must stay below intent.ruleThreshold (${String(tauLow)} >= ${String(ruleThreshold)})`);
|
|
750
|
+
const toStrong = number("intent.hysteresis.toStrong", intent.hysteresis?.toStrong, .8, 0, 1);
|
|
751
|
+
const toCheap = number("intent.hysteresis.toCheap", intent.hysteresis?.toCheap, .6, 0, 1);
|
|
752
|
+
if (toCheap >= toStrong) invalid("intent.hysteresis", `toCheap (${String(toCheap)}) must stay below toStrong (${String(toStrong)})`);
|
|
753
|
+
const rules = [];
|
|
754
|
+
const ruleIds = /* @__PURE__ */ new Set();
|
|
755
|
+
for (const [index, rule] of (intent.rules ?? []).entries()) {
|
|
756
|
+
const id = text(`intent.rules[${String(index)}].id`, rule.id, "");
|
|
757
|
+
if (ruleIds.has(id)) invalid(`intent.rules[${String(index)}].id`, `duplicates rule id ${JSON.stringify(id)}`);
|
|
758
|
+
ruleIds.add(id);
|
|
759
|
+
const priority = rule.priority ?? 0;
|
|
760
|
+
if (!Number.isFinite(priority)) invalid(`intent.rules[${String(index)}].priority`, "must be a finite number");
|
|
761
|
+
const patterns = [...rule.when?.patterns ?? []];
|
|
762
|
+
const tools = [...rule.when?.tools ?? []];
|
|
763
|
+
if (patterns.length === 0 && tools.length === 0) invalid(`intent.rules[${String(index)}].when`, "must declare at least one pattern or tool");
|
|
764
|
+
for (const [patternIndex, pattern] of patterns.entries()) {
|
|
765
|
+
if (typeof pattern !== "string" || pattern.length === 0) invalid(`intent.rules[${String(index)}].when.patterns[${String(patternIndex)}]`, "must be a non-empty regular expression");
|
|
766
|
+
try {
|
|
767
|
+
new RegExp(pattern, "u");
|
|
768
|
+
} catch (error) {
|
|
769
|
+
invalid(`intent.rules[${String(index)}].when.patterns[${String(patternIndex)}]`, `is not a valid regular expression (${String(error)})`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const [toolIndex, tool] of tools.entries()) if (typeof tool !== "string" || tool.trim().length === 0) invalid(`intent.rules[${String(index)}].when.tools[${String(toolIndex)}]`, "must be a non-empty tool name");
|
|
773
|
+
rules.push({
|
|
774
|
+
id,
|
|
775
|
+
when: {
|
|
776
|
+
patterns,
|
|
777
|
+
tools,
|
|
778
|
+
cwd: rule.when?.cwd ?? ""
|
|
779
|
+
},
|
|
780
|
+
tier: member(`intent.rules[${String(index)}].tier`, rule.tier, "strong", ["cheap", "strong"]),
|
|
781
|
+
priority
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
const judge = intent.judge ?? {};
|
|
785
|
+
const judgeModel = judge.model ?? "";
|
|
786
|
+
if (typeof judgeModel !== "string") invalid("intent.judge.model", "must be a string (empty = auto)");
|
|
787
|
+
const scenarios = Object.fromEntries(SCENARIOS.map((scenario) => [scenario, boolean(`intent.scenarios.${scenario}`, intent.scenarios?.[scenario], true)]));
|
|
788
|
+
return {
|
|
789
|
+
ruleThreshold,
|
|
790
|
+
attemptBand: {
|
|
791
|
+
enabled: boolean("intent.attemptBand.enabled", intent.attemptBand?.enabled, false),
|
|
792
|
+
tauLow
|
|
793
|
+
},
|
|
794
|
+
hysteresis: {
|
|
795
|
+
toStrong,
|
|
796
|
+
toCheap
|
|
797
|
+
},
|
|
798
|
+
rules,
|
|
799
|
+
judge: {
|
|
800
|
+
enabled: boolean("intent.judge.enabled", judge.enabled, true),
|
|
801
|
+
model: judgeModel,
|
|
802
|
+
temperature: number("intent.judge.temperature", judge.temperature, 0, 0, 2),
|
|
803
|
+
maxTokens: integer("intent.judge.maxTokens", judge.maxTokens, 16, 1, 4096),
|
|
804
|
+
cooldownMs: number("intent.judge.cooldownMs", judge.cooldownMs, 3e4, 0, 36e5),
|
|
805
|
+
timeoutMs: number("intent.judge.timeoutMs", judge.timeoutMs, 2e3, 1, 12e4),
|
|
806
|
+
unavailableSkip: integer("intent.judge.unavailableSkip", judge.unavailableSkip, 2, 0, 100)
|
|
807
|
+
},
|
|
808
|
+
scenarios,
|
|
809
|
+
costMode: member("intent.costMode", intent.costMode, "balanced", COST_MODES)
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/** Resolve the guard section. */
|
|
813
|
+
function resolveGuard(raw) {
|
|
814
|
+
const guard = raw ?? {};
|
|
815
|
+
const whitelist = [...guard.whitelist ?? []];
|
|
816
|
+
for (const [index, entry] of whitelist.entries()) if (typeof entry !== "string" || entry.trim().length === 0) invalid(`guard.whitelist[${String(index)}]`, "must be a non-empty string");
|
|
817
|
+
const protectedPaths = [...guard.protectedPaths ?? [
|
|
818
|
+
".dsh",
|
|
819
|
+
"AGENTS.md",
|
|
820
|
+
"package.json",
|
|
821
|
+
".github/workflows"
|
|
822
|
+
]];
|
|
823
|
+
for (const [index, entry] of protectedPaths.entries()) if (typeof entry !== "string" || entry.trim().length === 0) invalid(`guard.protectedPaths[${String(index)}]`, "must be a non-empty path or glob");
|
|
824
|
+
const tiers = guard.tiers === void 0 ? ["cheap"] : guard.tiers.map((tier, index) => {
|
|
825
|
+
if (tier !== "cheap") invalid(`guard.tiers[${String(index)}]`, "must be \"cheap\"");
|
|
826
|
+
return tier;
|
|
827
|
+
});
|
|
828
|
+
if (tiers.length === 0) invalid("guard.tiers", "must list at least one tier; use guard.enabled=false to disable the guard");
|
|
829
|
+
return {
|
|
830
|
+
enabled: boolean("guard.enabled", guard.enabled, true),
|
|
831
|
+
tiers,
|
|
832
|
+
whitelist,
|
|
833
|
+
protectedPaths,
|
|
834
|
+
interopDefend: member("guard.interopDefend", guard.interopDefend, "auto", ["auto", "none"])
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/** Resolve the escalation section. */
|
|
838
|
+
function resolveEscalation(raw) {
|
|
839
|
+
const escalation = raw ?? {};
|
|
840
|
+
return {
|
|
841
|
+
threshold: integer("escalation.threshold", escalation.threshold, 2, 1, 100),
|
|
842
|
+
windowMs: number("escalation.windowMs", escalation.windowMs, 6e4, 1, 864e5),
|
|
843
|
+
ttlMs: number("escalation.ttlMs", escalation.ttlMs, 18e4, 1, 864e5),
|
|
844
|
+
fallbackTtlMs: number("escalation.fallbackTtlMs", escalation.fallbackTtlMs, 3e5, 1, 864e5),
|
|
845
|
+
signature: boolean("escalation.signature", escalation.signature, true)
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/** Deep-freeze a resolved configuration tree. */
|
|
849
|
+
function deepFreeze(value) {
|
|
850
|
+
if (value !== null && typeof value === "object") {
|
|
851
|
+
for (const nested of Object.values(value)) deepFreeze(nested);
|
|
852
|
+
Object.freeze(value);
|
|
853
|
+
}
|
|
854
|
+
return value;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* The landing a tier actually resolves to. `followSession` tiers omit their
|
|
858
|
+
* effort, so two tiers that differ only by a configured-but-ignored effort are
|
|
859
|
+
* the same landing and must be rejected.
|
|
860
|
+
*/
|
|
861
|
+
function effectiveLanding(tier) {
|
|
862
|
+
return tier.followSession ? `${tier.provider}/${tier.model}@session` : `${tier.provider}/${tier.model}@${tier.effort}`;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Resolve raw config to the frozen runtime policy, re-judging every default,
|
|
866
|
+
* bound and cross-field requirement.
|
|
867
|
+
*
|
|
868
|
+
* @param raw - raw loader config; `undefined` for a bare row.
|
|
869
|
+
* @returns the frozen resolved config.
|
|
870
|
+
* @throws {Error} when a value is out of bounds or a cross-field requirement fails.
|
|
871
|
+
*/
|
|
872
|
+
function resolveConfig(raw) {
|
|
873
|
+
const tiers = raw?.tiers ?? {};
|
|
874
|
+
const strong = resolveTier("strong", tiers.strong, DEFAULT_STRONG);
|
|
875
|
+
const cheap = resolveTier("cheap", tiers.cheap, DEFAULT_CHEAP);
|
|
876
|
+
const strongTriple = effectiveLanding(strong);
|
|
877
|
+
if (strongTriple === effectiveLanding(cheap)) invalid("tiers", `strong and cheap resolve to the same landing (${strongTriple}); tiering would be a no-op`);
|
|
878
|
+
return deepFreeze({
|
|
879
|
+
tiers: {
|
|
880
|
+
strong,
|
|
881
|
+
cheap,
|
|
882
|
+
vision: {
|
|
883
|
+
provider: text("tiers.vision.provider", tiers.vision?.provider, DEFAULT_VISION.provider),
|
|
884
|
+
model: text("tiers.vision.model", tiers.vision?.model, DEFAULT_VISION.model)
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
intent: resolveIntent(raw?.intent),
|
|
888
|
+
guard: resolveGuard(raw?.guard),
|
|
889
|
+
escalation: resolveEscalation(raw?.escalation),
|
|
890
|
+
routingMode: member("routingMode", raw?.routingMode, "auto", ROUTING_MODES)
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Judge a configuration without keeping the resolved value. This is the
|
|
895
|
+
* save-time hook the `autotier` settings namespace registers, so a user write
|
|
896
|
+
* that violates a cross-field requirement is refused at the write instead of
|
|
897
|
+
* silently disabling the plugin.
|
|
898
|
+
*
|
|
899
|
+
* @param value - the configuration to judge.
|
|
900
|
+
* @throws {Error} when the configuration is invalid.
|
|
901
|
+
*/
|
|
902
|
+
function validateConfig(value) {
|
|
903
|
+
resolveConfig(value);
|
|
904
|
+
}
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/guard-rules.ts
|
|
907
|
+
/**
|
|
908
|
+
* High-impact command and path rules for the autotier guard.
|
|
909
|
+
*
|
|
910
|
+
* A TypeScript port of the dependency-free rule logic in `lib/pure.js` of
|
|
911
|
+
* `dsh-tier-router` (v0.5.0, MIT — see `THIRD_PARTY_NOTICES.md`): the
|
|
912
|
+
* `ARG_RUNNERS`/`DIRECT_RUNNERS` command-position detection, the 16
|
|
913
|
+
* `HIGH_IMPACT_COMMAND` patterns, and the 5 `HIGH_IMPACT_PATH` patterns. The
|
|
914
|
+
* upstream matching order, case-insensitivity, anchored command position, and
|
|
915
|
+
* 80/120-character truncation are preserved exactly; the port only adds stable
|
|
916
|
+
* rule ids and the `GuardMatch` shape the guard layer consumes.
|
|
917
|
+
*
|
|
918
|
+
* INTENTIONAL DELTA OVER UPSTREAM (the plugin's own Apache-2.0 addition, not
|
|
919
|
+
* upstream code): upstream never looks inside a `sh -c "..."` payload, so
|
|
920
|
+
* `sh -c "rm -rf /"` is a documented false negative. {@link SHELL_WRAPPERS} and
|
|
921
|
+
* {@link SHELL_WRAPPER_MAX_DEPTH} add a second pass that runs only after the
|
|
922
|
+
* upstream rules return no match: it strips a leading command runner, extracts
|
|
923
|
+
* the `-c` payload (single-dash flag clusters such as `-lc`, case-insensitive
|
|
924
|
+
* wrapper names, and the optional backslash escape included), unescapes it, and
|
|
925
|
+
* re-runs the same matcher on it (bounded by depth) under a
|
|
926
|
+
* `shell-wrapper:<inner rule>` id. No upstream verdict changes.
|
|
927
|
+
*
|
|
928
|
+
* Matching is deliberately conservative: it is a review/escalation signal, never
|
|
929
|
+
* a substitute for `dsh-defend`, approvals, or the sandbox policy.
|
|
930
|
+
* @module dsh-autotier/guard-rules
|
|
931
|
+
*/
|
|
932
|
+
/**
|
|
933
|
+
* Command runners that may prefix `rm` and still execute it, split by whether
|
|
934
|
+
* they legitimately carry their own arguments before the command.
|
|
935
|
+
*
|
|
936
|
+
* `ARG_RUNNERS` allow any arguments before `rm` (`env -i rm -rf`,
|
|
937
|
+
* `timeout 5 rm -rf`, `xargs -0 rm -rf`); `DIRECT_RUNNERS` take the command
|
|
938
|
+
* immediately (`nohup rm -rf`), because allowing arguments there would
|
|
939
|
+
* false-positive on harmless forms like `nohup echo rm -rf`.
|
|
940
|
+
*/
|
|
941
|
+
const ARG_RUNNERS = "sudo|env|timeout|nice|xargs|doas|setarch|stdbuf|ionice";
|
|
942
|
+
const DIRECT_RUNNERS = "command|exec|busybox|nohup|pkexec";
|
|
943
|
+
/**
|
|
944
|
+
* `rm` at command position: start of string, after a command separator
|
|
945
|
+
* (`;`, `&`, `|`), or after one of the runners above. `\\?` makes the leading
|
|
946
|
+
* backslash escape (`\rm -rf`) optional rather than required.
|
|
947
|
+
*/
|
|
948
|
+
const RECURSIVE_FORCE_RM = new RegExp("(^|[;&|]\\s*|\\b(" + ARG_RUNNERS + ")\\s+(?:\\S+\\s+)*" + "|\\b(" + DIRECT_RUNNERS + ")\\s+" + ")\\\\?rm(\\s+)", "i");
|
|
949
|
+
/** Command matches are trimmed and truncated to this many characters. */
|
|
950
|
+
const COMMAND_MATCH_LIMIT = 80;
|
|
951
|
+
/** Path matches are truncated to this many characters (upstream: whole path). */
|
|
952
|
+
const PATH_MATCH_LIMIT = 120;
|
|
953
|
+
/**
|
|
954
|
+
* Shell wrappers whose `-c` payload must be re-scanned (upstream misses these).
|
|
955
|
+
* This table and {@link SHELL_WRAPPER_MAX_DEPTH} are the plugin's own extension,
|
|
956
|
+
* not part of the upstream rule port.
|
|
957
|
+
*/
|
|
958
|
+
const SHELL_WRAPPERS = [
|
|
959
|
+
"sh",
|
|
960
|
+
"bash",
|
|
961
|
+
"zsh",
|
|
962
|
+
"dash",
|
|
963
|
+
"ksh"
|
|
964
|
+
];
|
|
965
|
+
/**
|
|
966
|
+
* The wrapper-name alternation in every letter case (`SH`, `Bash`, `sh`, ...)
|
|
967
|
+
* WITHOUT making the flag cluster case-insensitive: `-C` must not count as a
|
|
968
|
+
* `-c` cluster.
|
|
969
|
+
*/
|
|
970
|
+
const WRAPPER_NAMES = SHELL_WRAPPERS.map((word) => word.split("").map((letter) => `[${letter}${letter.toUpperCase()}]`).join("")).join("|");
|
|
971
|
+
/**
|
|
972
|
+
* A wrapper invocation at command position: separator/start, an optional
|
|
973
|
+
* backslash escape, wrapper name (any letter case), a single-dash flag cluster
|
|
974
|
+
* containing `c` (`-c`, `-lc`, `-ec`, ... but never a long option such as
|
|
975
|
+
* `--command`, which has no single-dash cluster before the payload), then a
|
|
976
|
+
* double-quoted, single-quoted, or bare payload. The quoted forms accept
|
|
977
|
+
* backslash escapes so nested quoting survives; the captured content is
|
|
978
|
+
* unescaped before it is re-scanned.
|
|
979
|
+
*/
|
|
980
|
+
const SHELL_WRAPPER_C = new RegExp(`(^|[;&|]\\s*)\\\\?(${WRAPPER_NAMES})\\s+-(?=[a-z]*c[a-z]*\\b)[a-z]+\\s+("((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'|(\\S+))`);
|
|
981
|
+
/** A command runner at the very start, with optional backslash escape. */
|
|
982
|
+
const RUNNER_AT_START = new RegExp(`^\\s*\\\\?(${ARG_RUNNERS}|${DIRECT_RUNNERS})\\s+`);
|
|
983
|
+
/** An argument-carrying runner at the very start. */
|
|
984
|
+
const ARG_RUNNER_AT_START = new RegExp(`^\\s*\\\\?(${ARG_RUNNERS})\\s+`);
|
|
985
|
+
/** One leading whitespace-delimited token. */
|
|
986
|
+
const LEADING_TOKEN = /^(\S+)\s+/;
|
|
987
|
+
/**
|
|
988
|
+
* The 16 upstream `HIGH_IMPACT_COMMAND` patterns, in upstream array order.
|
|
989
|
+
* `matchCommand` returns the first hit, so order is part of the contract:
|
|
990
|
+
* `sudo` precedes `wget-pipe-shell`, and the separate `rm` rule precedes all
|
|
991
|
+
* of these.
|
|
992
|
+
*/
|
|
993
|
+
const HIGH_IMPACT_COMMAND_RULES = [
|
|
994
|
+
{
|
|
995
|
+
id: "mkfs",
|
|
996
|
+
description: "mkfs: filesystem creation on a device",
|
|
997
|
+
pattern: /\bmkfs\.?[a-z]*\b/
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
id: "dd-write",
|
|
1001
|
+
description: "dd with if=/of=: raw device read/write",
|
|
1002
|
+
pattern: /\bdd\s+(if|of)=/
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
id: "sudo",
|
|
1006
|
+
description: "sudo: privilege escalation at command position",
|
|
1007
|
+
pattern: /(^|[;&|]\s*)sudo\b/
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
id: "shutdown",
|
|
1011
|
+
description: "shutdown/reboot/halt: system power control",
|
|
1012
|
+
pattern: /(^|[;&|]\s*)(shutdown|reboot|halt)\b/
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
id: "git-push-force",
|
|
1016
|
+
description: "git push --force/-f: remote history rewrite",
|
|
1017
|
+
pattern: /git\s+push\s+[^\n]*(-f\b|--force)/
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
id: "git-clean-force",
|
|
1021
|
+
description: "git clean -f: untracked file deletion",
|
|
1022
|
+
pattern: /git\s+clean\s+(-[a-z]*f[a-z]*\b)/
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
id: "find-delete",
|
|
1026
|
+
description: "find -delete: recursive file deletion",
|
|
1027
|
+
pattern: /find\s+[^\n]*\s+-delete\b/
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
id: "find-exec-rm",
|
|
1031
|
+
description: "find -exec rm: deletion through find",
|
|
1032
|
+
pattern: /find\s+[^\n]*-exec\s+[^\n]*\brm\b/
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
id: "shutil-rmtree",
|
|
1036
|
+
description: "shutil.rmtree(...): recursive Python deletion",
|
|
1037
|
+
pattern: /\b(shutil\.rmtree|rmtree)\s*\(/
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
id: "os-remove",
|
|
1041
|
+
description: "os.remove(...): Python file deletion",
|
|
1042
|
+
pattern: /\bos\.remove\s*\(/
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
id: "python-inline-delete",
|
|
1046
|
+
description: "python -c with inline deletion",
|
|
1047
|
+
pattern: /python[0-9.]*\s+-c\s+[^|;&\n]*(rmtree|os\.remove|shutil\.rmtree|rm\s+-rf)/
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
id: "curl-pipe-shell",
|
|
1051
|
+
description: "curl | sh: remote script execution",
|
|
1052
|
+
pattern: /curl\s+[^\n]*\|\s*(sudo\s+)?(ba)?sh\b/
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
id: "wget-pipe-shell",
|
|
1056
|
+
description: "wget | sh: remote script execution",
|
|
1057
|
+
pattern: /wget\s+[^\n]*\|\s*(sudo\s+)?(ba)?sh\b/
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
id: "chmod-ssh",
|
|
1061
|
+
description: "chmod on a .ssh/ path: SSH key permission change",
|
|
1062
|
+
pattern: /\bchmod\s+[0-7]{3,4}\s+[^\n]*\.ssh\//
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
id: "chown",
|
|
1066
|
+
description: "chown: ownership change",
|
|
1067
|
+
pattern: /\bchown\s/
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
id: "diskutil-erase",
|
|
1071
|
+
description: "diskutil erase/unmount: macOS disk destruction",
|
|
1072
|
+
pattern: /\bdiskutil\s+(eraseDisk|eraseVolume|zeroDisk|secureErase|unmountDisk)\b/
|
|
1073
|
+
}
|
|
1074
|
+
];
|
|
1075
|
+
/**
|
|
1076
|
+
* The 5 upstream `HIGH_IMPACT_PATH` patterns (credentials, keys, secrets), in
|
|
1077
|
+
* upstream array order. `.env` matches unless the suffix is an
|
|
1078
|
+
* example/sample/template name.
|
|
1079
|
+
*/
|
|
1080
|
+
const HIGH_IMPACT_PATH_RULES = [
|
|
1081
|
+
{
|
|
1082
|
+
id: "dotenv",
|
|
1083
|
+
description: ".env secrets file (example/sample/template names excluded)",
|
|
1084
|
+
pattern: /(^|\/)\.env(\.(?!example|sample|template)[^/]*)?$/i
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
id: "credentials",
|
|
1088
|
+
description: "credentials/secrets file or directory",
|
|
1089
|
+
pattern: /(^|\/)(credentials?|secrets?)(\.(json|ya?ml|toml|ini|env|key|pem|txt))?($|\/)/i
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
id: "ssh-dir",
|
|
1093
|
+
description: ".ssh/ directory",
|
|
1094
|
+
pattern: /(^|\/)\.ssh\//
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
id: "private-key",
|
|
1098
|
+
description: "id_rsa/id_ed25519/id_ecdsa/id_dsa key or .netrc",
|
|
1099
|
+
pattern: /(^|\/)(id_(rsa|ed25519|ecdsa|dsa)|\.netrc)(\b|\/)/i
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
id: "key-file",
|
|
1103
|
+
description: ".pem/.key/.p12/.pfx/.jks key material",
|
|
1104
|
+
pattern: /\.(pem|key|p12|pfx|jks)$/i
|
|
1105
|
+
}
|
|
1106
|
+
];
|
|
1107
|
+
/** Description reported for the separate recursive-force `rm` rule. */
|
|
1108
|
+
const RM_RECURSIVE_FORCE_DESCRIPTION = "command pattern rm -r/-f matched (recursive force delete)";
|
|
1109
|
+
/**
|
|
1110
|
+
* Match `rm` at command position with BOTH recursive (`-r`/`-R`/`--recursive`)
|
|
1111
|
+
* and force (`-f`/`--force`) flags, including split flags (`rm -r -f`) that a
|
|
1112
|
+
* single-token check misses.
|
|
1113
|
+
* @param command - one shell command string.
|
|
1114
|
+
* @returns the matched command-position text (trimmed, truncated), or null.
|
|
1115
|
+
*/
|
|
1116
|
+
function matchRecursiveForceRm(command) {
|
|
1117
|
+
const separator = command.match(RECURSIVE_FORCE_RM);
|
|
1118
|
+
if (separator === null) return null;
|
|
1119
|
+
const head = separator[0] ?? "";
|
|
1120
|
+
const rest = command.slice((separator.index ?? 0) + head.length);
|
|
1121
|
+
let flags = "";
|
|
1122
|
+
for (const token of rest.split(/\s+/)) if (/^--?[a-zA-Z]/.test(token)) flags += token.replace(/^-+/, "");
|
|
1123
|
+
else break;
|
|
1124
|
+
const lowered = flags.toLowerCase();
|
|
1125
|
+
if (!lowered.includes("r") || !lowered.includes("f")) return null;
|
|
1126
|
+
return head.trim().slice(0, COMMAND_MATCH_LIMIT);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Match the upstream command rules only: the recursive-force `rm` rule first
|
|
1130
|
+
* (upstream order), then {@link HIGH_IMPACT_COMMAND_RULES} in array order.
|
|
1131
|
+
*/
|
|
1132
|
+
function matchUpstreamCommand(command) {
|
|
1133
|
+
const rm = matchRecursiveForceRm(command);
|
|
1134
|
+
if (rm !== null) return {
|
|
1135
|
+
rule: "rm-recursive-force",
|
|
1136
|
+
description: RM_RECURSIVE_FORCE_DESCRIPTION,
|
|
1137
|
+
matched: rm
|
|
1138
|
+
};
|
|
1139
|
+
for (const rule of HIGH_IMPACT_COMMAND_RULES) {
|
|
1140
|
+
const match = command.match(rule.pattern);
|
|
1141
|
+
if (match !== null) return {
|
|
1142
|
+
rule: rule.id,
|
|
1143
|
+
description: rule.description,
|
|
1144
|
+
matched: (match[0] ?? "").trim().slice(0, COMMAND_MATCH_LIMIT)
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Strip leading command runners (and the arguments an argument-carrying runner
|
|
1151
|
+
* may carry) so a `sh -c ...` behind `env -i`, `timeout 5`, or `nohup` is still
|
|
1152
|
+
* seen at command position. Argument consumption stops at a shell wrapper,
|
|
1153
|
+
* matched case-insensitively and with the optional backslash escape removed.
|
|
1154
|
+
*/
|
|
1155
|
+
function stripRunnerPrefix(command) {
|
|
1156
|
+
let rest = command;
|
|
1157
|
+
for (;;) {
|
|
1158
|
+
const carriesArgs = ARG_RUNNER_AT_START.test(rest);
|
|
1159
|
+
const runner = RUNNER_AT_START.exec(rest);
|
|
1160
|
+
if (runner === null) return rest;
|
|
1161
|
+
rest = rest.slice((runner[0] ?? "").length);
|
|
1162
|
+
if (!carriesArgs) continue;
|
|
1163
|
+
for (;;) {
|
|
1164
|
+
const token = LEADING_TOKEN.exec(rest);
|
|
1165
|
+
const word = (token?.[1] ?? "").replace(/^\\/, "").toLowerCase();
|
|
1166
|
+
if (token === null || SHELL_WRAPPERS.includes(word)) break;
|
|
1167
|
+
rest = rest.slice((token[0] ?? "").length);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/** The `-c` payload of the first shell-wrapper invocation, or null. */
|
|
1172
|
+
function matchShellWrapperPayload(command) {
|
|
1173
|
+
const wrapper = SHELL_WRAPPER_C.exec(stripRunnerPrefix(command));
|
|
1174
|
+
if (wrapper === null) return null;
|
|
1175
|
+
return (wrapper[4] ?? wrapper[5] ?? wrapper[6] ?? "").replace(/\\(["'\\])/g, "$1");
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The full matcher: upstream rules first, then the bounded shell-wrapper
|
|
1179
|
+
* extension. `depth` counts wrapper unwrappings, so a payload nested deeper
|
|
1180
|
+
* than {@link SHELL_WRAPPER_MAX_DEPTH} is left unmatched.
|
|
1181
|
+
*/
|
|
1182
|
+
function matchCommandAtDepth(command, depth) {
|
|
1183
|
+
const upstream = matchUpstreamCommand(command);
|
|
1184
|
+
if (upstream !== null) return upstream;
|
|
1185
|
+
if (depth >= 3) return null;
|
|
1186
|
+
const payload = matchShellWrapperPayload(command);
|
|
1187
|
+
if (payload === null) return null;
|
|
1188
|
+
const inner = matchCommandAtDepth(payload, depth + 1);
|
|
1189
|
+
if (inner === null) return null;
|
|
1190
|
+
return {
|
|
1191
|
+
rule: `shell-wrapper:${inner.rule}`,
|
|
1192
|
+
description: `shell wrapper -c payload: ${inner.description}`,
|
|
1193
|
+
matched: inner.matched.slice(0, COMMAND_MATCH_LIMIT)
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Match one shell command string against the recursive-force `rm` rule first
|
|
1198
|
+
* (upstream order) and then the command rules in array order; only when those
|
|
1199
|
+
* find nothing, re-scan a `sh -c`-style payload (see the module header delta).
|
|
1200
|
+
* @param command - one shell command string.
|
|
1201
|
+
* @returns the first match, or null when the command is not high impact.
|
|
1202
|
+
*/
|
|
1203
|
+
function matchCommand(command) {
|
|
1204
|
+
return matchCommandAtDepth(command, 0);
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Match one file path (a write/edit target) against the path rules in array
|
|
1208
|
+
* order.
|
|
1209
|
+
* @param filePath - one target path.
|
|
1210
|
+
* @returns the first match, or null when the path is not high impact.
|
|
1211
|
+
*/
|
|
1212
|
+
function matchPath(filePath) {
|
|
1213
|
+
const rule = HIGH_IMPACT_PATH_RULES.find((candidate) => candidate.pattern.test(filePath));
|
|
1214
|
+
if (rule === void 0) return null;
|
|
1215
|
+
return {
|
|
1216
|
+
rule: rule.id,
|
|
1217
|
+
description: rule.description,
|
|
1218
|
+
matched: filePath.slice(0, PATH_MATCH_LIMIT)
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Credential/secret path classification for protected-path review: true when
|
|
1223
|
+
* any of the {@link HIGH_IMPACT_PATH_RULES} matches, i.e. exactly the paths
|
|
1224
|
+
* {@link matchPath} reports.
|
|
1225
|
+
*/
|
|
1226
|
+
function isCredentialPath(filePath) {
|
|
1227
|
+
return HIGH_IMPACT_PATH_RULES.some((rule) => rule.pattern.test(filePath));
|
|
1228
|
+
}
|
|
1229
|
+
//#endregion
|
|
1230
|
+
//#region src/guard.ts
|
|
1231
|
+
/** Tool argument keys that carry a shell command. */
|
|
1232
|
+
const COMMAND_KEYS = [
|
|
1233
|
+
"command",
|
|
1234
|
+
"cmd",
|
|
1235
|
+
"script"
|
|
1236
|
+
];
|
|
1237
|
+
/**
|
|
1238
|
+
* Tool argument keys that carry a filesystem target. `file_path` is specific
|
|
1239
|
+
* enough to trust on any tool; the looser keys are only read from tools whose
|
|
1240
|
+
* name says they write, so a tool with an unrelated `target` argument (a window
|
|
1241
|
+
* handle, a selector) cannot trip the path rules.
|
|
1242
|
+
*/
|
|
1243
|
+
const STRICT_PATH_KEYS = ["file_path"];
|
|
1244
|
+
const LOOSE_PATH_KEYS = [
|
|
1245
|
+
"path",
|
|
1246
|
+
"target",
|
|
1247
|
+
"file",
|
|
1248
|
+
"filename"
|
|
1249
|
+
];
|
|
1250
|
+
/** Tool names whose `path`-like arguments are filesystem targets. */
|
|
1251
|
+
const WRITE_TOOL_PATTERN = /(?:write|edit|patch|create|delete|remove|move|copy|rename|save|apply|mkdir|touch)/iu;
|
|
1252
|
+
/**
|
|
1253
|
+
* Secret shapes that must never reach a model-visible denial reason or the
|
|
1254
|
+
* session log. The matched snippet is the one place user text could leak into
|
|
1255
|
+
* the guard's output, so it is redacted before it is composed.
|
|
1256
|
+
*/
|
|
1257
|
+
const SECRET_PATTERNS = [
|
|
1258
|
+
/(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/giu,
|
|
1259
|
+
/\bsk-[A-Za-z0-9._-]{8,}/gu,
|
|
1260
|
+
/\bgh[pousr]_[A-Za-z0-9]{8,}/gu,
|
|
1261
|
+
/((?:password|passwd|token|secret|api[_-]?key)\s*[=:]\s*)\S+/giu
|
|
1262
|
+
];
|
|
1263
|
+
/** Redact credential-shaped spans from one snippet. */
|
|
1264
|
+
function redactSnippet(text) {
|
|
1265
|
+
let redacted = text;
|
|
1266
|
+
for (const pattern of SECRET_PATTERNS) redacted = redacted.replace(pattern, "$1<redacted>");
|
|
1267
|
+
return redacted;
|
|
1268
|
+
}
|
|
1269
|
+
/** Extract the first string-valued key present in `args`. */
|
|
1270
|
+
function pick(args, keys) {
|
|
1271
|
+
if (args === null || typeof args !== "object") return void 0;
|
|
1272
|
+
const record = args;
|
|
1273
|
+
for (const key of keys) {
|
|
1274
|
+
const value = record[key];
|
|
1275
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Whether a path sits inside a configured protected surface. Windows resolves
|
|
1280
|
+
* case-insensitively and strips trailing dots/spaces from each segment, so the
|
|
1281
|
+
* comparison normalizes both before matching; otherwise `PACKAGE.JSON` and
|
|
1282
|
+
* `package.json.` would slip past the guard.
|
|
1283
|
+
*/
|
|
1284
|
+
function isProtectedPath(path, protectedPaths) {
|
|
1285
|
+
const normalize = (value) => {
|
|
1286
|
+
const slashed = value.replace(/\\/gu, "/");
|
|
1287
|
+
if (process.platform !== "win32") return slashed;
|
|
1288
|
+
return slashed.split("/").map((segment) => segment.replace(/[. ]+$/u, "").toLowerCase()).join("/");
|
|
1289
|
+
};
|
|
1290
|
+
const normalized = normalize(path);
|
|
1291
|
+
for (const entry of protectedPaths) {
|
|
1292
|
+
const needle = normalize(entry).replace(/^\.\//u, "");
|
|
1293
|
+
if (normalized === needle || normalized.endsWith(`/${needle}`) || normalized.includes(`/${needle}/`)) return entry;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
/** Whether a command or path is whitelisted (exact, or a path/word boundary prefix). */
|
|
1297
|
+
function isWhitelisted(value, toolName, whitelist) {
|
|
1298
|
+
if (whitelist.includes(toolName)) return true;
|
|
1299
|
+
if (value === void 0) return false;
|
|
1300
|
+
return whitelist.some((entry) => {
|
|
1301
|
+
if (value === entry) return true;
|
|
1302
|
+
const rest = value.slice(entry.length);
|
|
1303
|
+
if (!value.startsWith(entry) || rest === "") return false;
|
|
1304
|
+
return /^[\s/\\]/u.test(rest);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Judge one tool call.
|
|
1309
|
+
*
|
|
1310
|
+
* Order: the guard must be enabled and the executing tier protected, then the
|
|
1311
|
+
* whitelist, then the credential/command rules, then the protected-path review
|
|
1312
|
+
* rule. A denial names the rule and tells the model to escalate instead of
|
|
1313
|
+
* retrying.
|
|
1314
|
+
*
|
|
1315
|
+
* @param input - the call, the executing tier and the live configuration.
|
|
1316
|
+
* @returns the verdict.
|
|
1317
|
+
*/
|
|
1318
|
+
function evaluateToolCall(input) {
|
|
1319
|
+
const { config, tier } = input;
|
|
1320
|
+
if (!config.guard.enabled) return {
|
|
1321
|
+
action: "allow",
|
|
1322
|
+
reason: "",
|
|
1323
|
+
rule: "",
|
|
1324
|
+
axis: "none"
|
|
1325
|
+
};
|
|
1326
|
+
if (!config.guard.tiers.includes("cheap") || tier !== "cheap") return {
|
|
1327
|
+
action: "allow",
|
|
1328
|
+
reason: "",
|
|
1329
|
+
rule: "",
|
|
1330
|
+
axis: "none"
|
|
1331
|
+
};
|
|
1332
|
+
const command = pick(input.args, COMMAND_KEYS);
|
|
1333
|
+
const path = WRITE_TOOL_PATTERN.test(input.toolName) ? pick(input.args, STRICT_PATH_KEYS) ?? pick(input.args, LOOSE_PATH_KEYS) : void 0;
|
|
1334
|
+
if (isWhitelisted(command ?? path, input.toolName, config.guard.whitelist)) return {
|
|
1335
|
+
action: "allow",
|
|
1336
|
+
reason: "",
|
|
1337
|
+
rule: "",
|
|
1338
|
+
axis: "none"
|
|
1339
|
+
};
|
|
1340
|
+
if (path !== void 0) {
|
|
1341
|
+
const protectedEntry = isProtectedPath(path, config.guard.protectedPaths);
|
|
1342
|
+
if (protectedEntry !== void 0) return {
|
|
1343
|
+
action: "deny",
|
|
1344
|
+
rule: "protected-path",
|
|
1345
|
+
axis: "protected-path",
|
|
1346
|
+
reason: `dsh-autotier guard: "${redactSnippet(path)}" is a protected surface (${protectedEntry}). Modifying it requires the strong tier; report what you intend to change instead of retrying.`
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
if (command !== void 0) {
|
|
1350
|
+
const hit = matchCommand(command);
|
|
1351
|
+
if (hit !== null) return {
|
|
1352
|
+
action: "deny",
|
|
1353
|
+
rule: hit.rule,
|
|
1354
|
+
axis: "command",
|
|
1355
|
+
reason: `dsh-autotier guard: ${redactSnippet(hit.description)}. This command is denied while the cheap tier executes; the tier will escalate if the task needs it — do not retry the command.`
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
if (path !== void 0) {
|
|
1359
|
+
const hit = matchPath(path);
|
|
1360
|
+
if (hit !== null) return {
|
|
1361
|
+
action: "deny",
|
|
1362
|
+
rule: hit.rule,
|
|
1363
|
+
axis: "path",
|
|
1364
|
+
reason: `dsh-autotier guard: ${redactSnippet(hit.description)}. Credential and key material is denied while the cheap tier executes; the tier will escalate if the task needs it.`
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
return {
|
|
1368
|
+
action: "allow",
|
|
1369
|
+
reason: "",
|
|
1370
|
+
rule: "",
|
|
1371
|
+
axis: "none"
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
/** The tier the guard believes is executing one call. */
|
|
1375
|
+
function tierOf(states, agent) {
|
|
1376
|
+
if (agent === void 0) return void 0;
|
|
1377
|
+
const state = states.for(agent);
|
|
1378
|
+
if (state.override === "strong") return "strong";
|
|
1379
|
+
if (state.override === "off") return void 0;
|
|
1380
|
+
if (state.override === "cheap") return "cheap";
|
|
1381
|
+
if (state.escalation !== void 0 && state.escalation.until > Date.now()) return "strong";
|
|
1382
|
+
return state.appliedTier ?? "cheap";
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Register the `tools/pre-execute` guard.
|
|
1386
|
+
*
|
|
1387
|
+
* The listener is registered with `{ prepend: true }` so a denial claims the
|
|
1388
|
+
* call before any pass-through listener; every allowed call awaits `next()`.
|
|
1389
|
+
*
|
|
1390
|
+
* @param options - the plugin context, service and state store.
|
|
1391
|
+
*/
|
|
1392
|
+
function registerGuardHook({ ctx, service, states }) {
|
|
1393
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
1394
|
+
const agent = exec.agent;
|
|
1395
|
+
try {
|
|
1396
|
+
const tier = tierOf(states, agent);
|
|
1397
|
+
if (tier === void 0) return next();
|
|
1398
|
+
const sandbox = ctx.get("sandboxPolicy");
|
|
1399
|
+
const sandboxMode = sandbox === void 0 || agent === void 0 ? void 0 : sandbox.resolve({ session: agent.session }).mode;
|
|
1400
|
+
const verdict = evaluateToolCall({
|
|
1401
|
+
toolName: exec.name,
|
|
1402
|
+
args: exec.arguments,
|
|
1403
|
+
tier,
|
|
1404
|
+
config: service.config(),
|
|
1405
|
+
...sandboxMode === void 0 ? {} : { sandboxMode }
|
|
1406
|
+
});
|
|
1407
|
+
if (verdict.action === "allow") return next();
|
|
1408
|
+
ctx.logger.warn("dsh-autotier: guard denied %s on the %s tier (rule %s)%s", exec.name, tier, verdict.rule, sandboxMode === void 0 ? "" : ` [sandbox ${sandboxMode}]`);
|
|
1409
|
+
if (agent !== void 0) {
|
|
1410
|
+
const state = states.for(agent);
|
|
1411
|
+
state.denials += 1;
|
|
1412
|
+
state.lastDenial = verdict.rule;
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
kind: "deny",
|
|
1416
|
+
reason: verdict.reason
|
|
1417
|
+
};
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
ctx.logger.error("dsh-autotier: guard malfunction (%o); forcing escalation and denying the call", error);
|
|
1420
|
+
if (agent !== void 0) {
|
|
1421
|
+
const state = states.for(agent);
|
|
1422
|
+
const config = service.config();
|
|
1423
|
+
const now = Date.now();
|
|
1424
|
+
noteFailure(state, `guard|${String(error)}`, config, now);
|
|
1425
|
+
state.escalation = {
|
|
1426
|
+
count: config.escalation.threshold,
|
|
1427
|
+
signature: `guard|${String(error)}`,
|
|
1428
|
+
until: now + config.escalation.ttlMs,
|
|
1429
|
+
rung: (state.escalation?.rung ?? 0) + 1,
|
|
1430
|
+
lastAt: now
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
1434
|
+
kind: "deny",
|
|
1435
|
+
reason: "dsh-autotier guard: the guard itself failed, so this call was denied. The session is escalated to the strong tier — re-run the call there."
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
}, { prepend: true });
|
|
1439
|
+
}
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/intent.ts
|
|
1442
|
+
/** Compile the resolved rule table, ordered by descending priority. */
|
|
1443
|
+
function compileRules(rules) {
|
|
1444
|
+
return rules.map((rule) => ({
|
|
1445
|
+
id: rule.id,
|
|
1446
|
+
patterns: rule.when.patterns.map((pattern) => new RegExp(pattern, "u")),
|
|
1447
|
+
tools: rule.when.tools,
|
|
1448
|
+
cwd: rule.when.cwd,
|
|
1449
|
+
tier: rule.tier,
|
|
1450
|
+
priority: rule.priority
|
|
1451
|
+
})).sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id));
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Evaluate the declarative rule table. Rules are already sorted by descending
|
|
1455
|
+
* priority; the first match wins. A guard denial always outranks a rule (the
|
|
1456
|
+
* guard runs on `tools/pre-execute`, after the routing decision, and denies
|
|
1457
|
+
* regardless of tier).
|
|
1458
|
+
*
|
|
1459
|
+
* @param rules - compiled rules.
|
|
1460
|
+
* @param input - the live input facts.
|
|
1461
|
+
* @returns the winning hit, or null when no rule matches.
|
|
1462
|
+
*/
|
|
1463
|
+
function evaluateRules(rules, input) {
|
|
1464
|
+
for (const rule of rules) {
|
|
1465
|
+
if (rule.cwd !== "" && !input.cwd.startsWith(rule.cwd)) continue;
|
|
1466
|
+
const toolHit = rule.tools.some((tool) => input.toolNames.includes(tool));
|
|
1467
|
+
const patternHit = rule.patterns.some((pattern) => pattern.test(input.text));
|
|
1468
|
+
if (rule.tools.length > 0 && rule.patterns.length > 0) {
|
|
1469
|
+
if (!toolHit || !patternHit) continue;
|
|
1470
|
+
} else if (rule.tools.length > 0) {
|
|
1471
|
+
if (!toolHit) continue;
|
|
1472
|
+
} else if (!patternHit) continue;
|
|
1473
|
+
return {
|
|
1474
|
+
id: rule.id,
|
|
1475
|
+
tier: rule.tier
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
/** Which tier each scenario belongs to by default. */
|
|
1481
|
+
const SCENARIO_TIER = {
|
|
1482
|
+
coding: "cheap",
|
|
1483
|
+
review: "strong",
|
|
1484
|
+
planning: "strong",
|
|
1485
|
+
retrieval: "cheap",
|
|
1486
|
+
batch: "cheap",
|
|
1487
|
+
daily: "cheap",
|
|
1488
|
+
longText: "strong",
|
|
1489
|
+
multimodal: "strong"
|
|
1490
|
+
};
|
|
1491
|
+
/** Scenario evaluation order: specific/expensive first, `daily` last (tie-break order). */
|
|
1492
|
+
const SCENARIO_ORDER = [
|
|
1493
|
+
"planning",
|
|
1494
|
+
"review",
|
|
1495
|
+
"coding",
|
|
1496
|
+
"batch",
|
|
1497
|
+
"retrieval",
|
|
1498
|
+
"longText",
|
|
1499
|
+
"multimodal",
|
|
1500
|
+
"daily"
|
|
1501
|
+
];
|
|
1502
|
+
/**
|
|
1503
|
+
* Bilingual keyword tables. ASCII terms are matched with word boundaries
|
|
1504
|
+
* (case-insensitive); CJK terms are matched as substrings and a scenario needs
|
|
1505
|
+
* at least two distinct CJK hits before they count, so a single two-character
|
|
1506
|
+
* word cannot drag a turn into a scenario.
|
|
1507
|
+
*/
|
|
1508
|
+
const KEYWORDS = {
|
|
1509
|
+
coding: [
|
|
1510
|
+
"implement",
|
|
1511
|
+
"refactor",
|
|
1512
|
+
"fix",
|
|
1513
|
+
"bug",
|
|
1514
|
+
"test",
|
|
1515
|
+
"function",
|
|
1516
|
+
"class",
|
|
1517
|
+
"endpoint",
|
|
1518
|
+
"api",
|
|
1519
|
+
"代码",
|
|
1520
|
+
"实现",
|
|
1521
|
+
"修复",
|
|
1522
|
+
"重构",
|
|
1523
|
+
"测试",
|
|
1524
|
+
"函数",
|
|
1525
|
+
"接口",
|
|
1526
|
+
"编译",
|
|
1527
|
+
"报错"
|
|
1528
|
+
],
|
|
1529
|
+
review: [
|
|
1530
|
+
"review",
|
|
1531
|
+
"audit",
|
|
1532
|
+
"security",
|
|
1533
|
+
"vulnerability",
|
|
1534
|
+
"hardening",
|
|
1535
|
+
"审查",
|
|
1536
|
+
"审计",
|
|
1537
|
+
"安全",
|
|
1538
|
+
"漏洞",
|
|
1539
|
+
"评审"
|
|
1540
|
+
],
|
|
1541
|
+
planning: [
|
|
1542
|
+
"plan",
|
|
1543
|
+
"design",
|
|
1544
|
+
"architect",
|
|
1545
|
+
"architecture",
|
|
1546
|
+
"roadmap",
|
|
1547
|
+
"migrate",
|
|
1548
|
+
"migration",
|
|
1549
|
+
"规划",
|
|
1550
|
+
"设计",
|
|
1551
|
+
"架构",
|
|
1552
|
+
"方案",
|
|
1553
|
+
"迁移"
|
|
1554
|
+
],
|
|
1555
|
+
retrieval: [
|
|
1556
|
+
"where",
|
|
1557
|
+
"find",
|
|
1558
|
+
"search",
|
|
1559
|
+
"locate",
|
|
1560
|
+
"grep",
|
|
1561
|
+
"查找",
|
|
1562
|
+
"搜索",
|
|
1563
|
+
"定位",
|
|
1564
|
+
"在哪"
|
|
1565
|
+
],
|
|
1566
|
+
batch: [
|
|
1567
|
+
"batch",
|
|
1568
|
+
"bulk",
|
|
1569
|
+
"every",
|
|
1570
|
+
"rename",
|
|
1571
|
+
"批量",
|
|
1572
|
+
"全部",
|
|
1573
|
+
"每个",
|
|
1574
|
+
"遍历"
|
|
1575
|
+
],
|
|
1576
|
+
daily: [
|
|
1577
|
+
"hello",
|
|
1578
|
+
"hi",
|
|
1579
|
+
"hey",
|
|
1580
|
+
"thanks",
|
|
1581
|
+
"thank",
|
|
1582
|
+
"weather",
|
|
1583
|
+
"你好",
|
|
1584
|
+
"您好",
|
|
1585
|
+
"谢谢",
|
|
1586
|
+
"天气"
|
|
1587
|
+
],
|
|
1588
|
+
longText: [],
|
|
1589
|
+
multimodal: []
|
|
1590
|
+
};
|
|
1591
|
+
/** Whole-string greetings classify as daily with high confidence. */
|
|
1592
|
+
const GREETING = /^(?:hi|hello|hey|thanks|thank you|你好|您好|谢谢|早上好|晚上好)[\s!.。,,!!]*$/iu;
|
|
1593
|
+
/** Explicit intent patterns that short-circuit keyword scoring. */
|
|
1594
|
+
const EXPLICIT = [
|
|
1595
|
+
{
|
|
1596
|
+
scenario: "planning",
|
|
1597
|
+
confidence: .97,
|
|
1598
|
+
pattern: /^(?:please\s+)?(?:plan|design|architect)\b/iu
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
scenario: "planning",
|
|
1602
|
+
confidence: .95,
|
|
1603
|
+
pattern: /(?:规划|架构设计|方案设计|技术方案|从零实现)/u
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
scenario: "review",
|
|
1607
|
+
confidence: .96,
|
|
1608
|
+
pattern: /\b(?:code review|review the|audit the|security review)\b/iu
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
scenario: "review",
|
|
1612
|
+
confidence: .94,
|
|
1613
|
+
pattern: /(?:代码审查|安全审计|评审一下)/u
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
scenario: "batch",
|
|
1617
|
+
confidence: .95,
|
|
1618
|
+
pattern: /\b(?:batch|bulk)\b/iu
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
scenario: "batch",
|
|
1622
|
+
confidence: .94,
|
|
1623
|
+
pattern: /(?:批量处理|批量修改|批量重命名)/u
|
|
1624
|
+
}
|
|
1625
|
+
];
|
|
1626
|
+
/** Words that mark a multi-step or whole-system request. */
|
|
1627
|
+
const HARD_HINTS = [
|
|
1628
|
+
"and then",
|
|
1629
|
+
"step by step",
|
|
1630
|
+
"multi-step",
|
|
1631
|
+
"end to end",
|
|
1632
|
+
"end-to-end",
|
|
1633
|
+
"entire",
|
|
1634
|
+
"all of",
|
|
1635
|
+
"migrate",
|
|
1636
|
+
"refactor",
|
|
1637
|
+
"architecture",
|
|
1638
|
+
"production",
|
|
1639
|
+
"从零",
|
|
1640
|
+
"完整",
|
|
1641
|
+
"整个",
|
|
1642
|
+
"多步",
|
|
1643
|
+
"端到端",
|
|
1644
|
+
"全流程"
|
|
1645
|
+
];
|
|
1646
|
+
/** Count non-overlapping occurrences of a fence marker. */
|
|
1647
|
+
function countFences(text) {
|
|
1648
|
+
return (text.match(/```/gu) ?? []).length;
|
|
1649
|
+
}
|
|
1650
|
+
/** Count ASCII word-boundary hits, case-insensitive. */
|
|
1651
|
+
function asciiHits(text, term) {
|
|
1652
|
+
const pattern = new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\b`, "giu");
|
|
1653
|
+
return (text.match(pattern) ?? []).length;
|
|
1654
|
+
}
|
|
1655
|
+
/** Count CJK substring hits. */
|
|
1656
|
+
function cjkHits(text, term) {
|
|
1657
|
+
let count = 0;
|
|
1658
|
+
let index = text.indexOf(term);
|
|
1659
|
+
while (index !== -1) {
|
|
1660
|
+
count += 1;
|
|
1661
|
+
index = text.indexOf(term, index + term.length);
|
|
1662
|
+
}
|
|
1663
|
+
return count;
|
|
1664
|
+
}
|
|
1665
|
+
/** Score one scenario against the text, applying the CJK co-occurrence rule. */
|
|
1666
|
+
function scoreScenario(text, scenario) {
|
|
1667
|
+
let asciiScore = 0;
|
|
1668
|
+
let cjkScore = 0;
|
|
1669
|
+
let cjkTerms = 0;
|
|
1670
|
+
for (const term of KEYWORDS[scenario]) {
|
|
1671
|
+
const isAscii = /^[\x20-\x7E]+$/u.test(term);
|
|
1672
|
+
const hits = isAscii ? asciiHits(text, term) : cjkHits(text, term);
|
|
1673
|
+
if (hits === 0) continue;
|
|
1674
|
+
const weight = term.length > 3 ? 2 : 1;
|
|
1675
|
+
if (isAscii) asciiScore += hits * weight;
|
|
1676
|
+
else {
|
|
1677
|
+
cjkScore += hits * weight;
|
|
1678
|
+
cjkTerms += 1;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return asciiScore + (cjkTerms >= 2 ? cjkScore : 0);
|
|
1682
|
+
}
|
|
1683
|
+
/** Compute the structural signal vector. */
|
|
1684
|
+
function computeSignals(input) {
|
|
1685
|
+
const chars = input.text.length;
|
|
1686
|
+
const estTokens = Math.ceil(chars / 4);
|
|
1687
|
+
const tokenBands = [
|
|
1688
|
+
4e3,
|
|
1689
|
+
12e3,
|
|
1690
|
+
3e4
|
|
1691
|
+
].filter((band) => estTokens >= band).length;
|
|
1692
|
+
const fences = countFences(input.text);
|
|
1693
|
+
const toolCalls = input.toolNames.length;
|
|
1694
|
+
const lower = input.text.toLowerCase();
|
|
1695
|
+
const hardHints = HARD_HINTS.filter((hint) => lower.includes(hint)).length > 0 ? 1 : 0;
|
|
1696
|
+
let score = tokenBands;
|
|
1697
|
+
if (toolCalls >= 1) score += 1;
|
|
1698
|
+
if (toolCalls >= 4) score += 1;
|
|
1699
|
+
if (fences >= 1) score += 1;
|
|
1700
|
+
if (fences >= 4) score += 1;
|
|
1701
|
+
score += hardHints;
|
|
1702
|
+
if (input.messageCount >= 12) score += 1;
|
|
1703
|
+
if (input.messageCount >= 30) score += 1;
|
|
1704
|
+
return {
|
|
1705
|
+
chars,
|
|
1706
|
+
estTokens,
|
|
1707
|
+
tokenBands,
|
|
1708
|
+
fences,
|
|
1709
|
+
toolCalls,
|
|
1710
|
+
messageCount: input.messageCount,
|
|
1711
|
+
hardHints,
|
|
1712
|
+
score
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/** The token band index used in a fingerprint. */
|
|
1716
|
+
function tokenBand(estTokens) {
|
|
1717
|
+
if (estTokens < 4e3) return 0;
|
|
1718
|
+
if (estTokens < 12e3) return 1;
|
|
1719
|
+
if (estTokens < 3e4) return 2;
|
|
1720
|
+
return 3;
|
|
1721
|
+
}
|
|
1722
|
+
/** The fence band index used in a fingerprint. */
|
|
1723
|
+
function fenceBand(fences) {
|
|
1724
|
+
if (fences === 0) return 0;
|
|
1725
|
+
if (fences < 4) return 1;
|
|
1726
|
+
return 2;
|
|
1727
|
+
}
|
|
1728
|
+
/** Build the fingerprint key for one classification. */
|
|
1729
|
+
function fingerprintOf(scenario, signals) {
|
|
1730
|
+
return `${scenario}|${tokenBand(signals.estTokens)}|${fenceBand(signals.fences)}`;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Classify one user input. Deterministic and token-free; the caller decides
|
|
1734
|
+
* whether the returned confidence warrants a judge call.
|
|
1735
|
+
*
|
|
1736
|
+
* @param input - the live input facts.
|
|
1737
|
+
* @param options - compiled rules and scenario switches.
|
|
1738
|
+
* @returns the verdict with its reasons.
|
|
1739
|
+
*/
|
|
1740
|
+
function classifyIntent(input, options) {
|
|
1741
|
+
const signals = computeSignals(input);
|
|
1742
|
+
const reasons = [];
|
|
1743
|
+
const enabled = (scenario) => options.scenarios[scenario];
|
|
1744
|
+
const finish = (scenario, confidence, shortCircuit, keywordScore) => {
|
|
1745
|
+
const tier = signals.score >= (options.signalThreshold ?? 3) ? "strong" : SCENARIO_TIER[scenario];
|
|
1746
|
+
if (signals.score >= (options.signalThreshold ?? 3) && SCENARIO_TIER[scenario] === "cheap") reasons.push(`structural signals (score ${String(signals.score)}) force the strong tier`);
|
|
1747
|
+
return {
|
|
1748
|
+
scenario,
|
|
1749
|
+
tier,
|
|
1750
|
+
confidence,
|
|
1751
|
+
keywordScore,
|
|
1752
|
+
signals,
|
|
1753
|
+
reasons,
|
|
1754
|
+
shortCircuit,
|
|
1755
|
+
fingerprint: fingerprintOf(scenario, signals)
|
|
1756
|
+
};
|
|
1757
|
+
};
|
|
1758
|
+
if (input.hasImage && enabled("multimodal")) {
|
|
1759
|
+
reasons.push("the message carries an image");
|
|
1760
|
+
return finish("multimodal", .98, "image", 0);
|
|
1761
|
+
}
|
|
1762
|
+
if (signals.chars > 12e3 && enabled("longText")) {
|
|
1763
|
+
reasons.push(`long input (${String(signals.chars)} chars)`);
|
|
1764
|
+
return finish("longText", .96, "long-text", 0);
|
|
1765
|
+
}
|
|
1766
|
+
if (GREETING.test(input.text.trim()) && enabled("daily")) {
|
|
1767
|
+
reasons.push("whole-message greeting");
|
|
1768
|
+
return finish("daily", .92, "greeting", 0);
|
|
1769
|
+
}
|
|
1770
|
+
for (const entry of EXPLICIT) if (enabled(entry.scenario) && entry.pattern.test(input.text)) {
|
|
1771
|
+
reasons.push(`explicit ${entry.scenario} intent`);
|
|
1772
|
+
return finish(entry.scenario, entry.confidence, "explicit", 0);
|
|
1773
|
+
}
|
|
1774
|
+
let best = {
|
|
1775
|
+
scenario: "daily",
|
|
1776
|
+
score: 0
|
|
1777
|
+
};
|
|
1778
|
+
for (const scenario of SCENARIO_ORDER) {
|
|
1779
|
+
if (!enabled(scenario)) continue;
|
|
1780
|
+
const score = scoreScenario(input.text, scenario);
|
|
1781
|
+
if (score > best.score) best = {
|
|
1782
|
+
scenario,
|
|
1783
|
+
score
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
const keywordScore = best.score;
|
|
1787
|
+
const scenario = keywordScore === 0 ? "daily" : best.scenario;
|
|
1788
|
+
const confidence = keywordScore === 0 ? .25 : Math.min(.96, .52 + keywordScore * .1);
|
|
1789
|
+
if (keywordScore > 0) reasons.push(`keyword score ${String(keywordScore)} for ${scenario}`);
|
|
1790
|
+
else reasons.push("no keyword matched; defaulting to the cheap tier");
|
|
1791
|
+
return finish(scenario, confidence, void 0, keywordScore);
|
|
1792
|
+
}
|
|
1793
|
+
/** Wilson score interval lower bound for `ok` successes in `n` trials. */
|
|
1794
|
+
function wilsonLowerBound(ok, n, z = 1.96) {
|
|
1795
|
+
if (n <= 0) return 0;
|
|
1796
|
+
const phat = ok / n;
|
|
1797
|
+
const denom = 1 + z * z / n;
|
|
1798
|
+
const centre = phat + z * z / (2 * n);
|
|
1799
|
+
const margin = z * Math.sqrt((phat * (1 - phat) + z * z / (4 * n)) / n);
|
|
1800
|
+
return Math.max(0, (centre - margin) / denom);
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Per-fingerprint win-rate posteriors. Labels come from terminal task outcomes
|
|
1804
|
+
* only (never from the router's own judge call), and every write decays old
|
|
1805
|
+
* counts once per half-life so a shape's reputation can recover.
|
|
1806
|
+
*/
|
|
1807
|
+
var PosteriorTable = class {
|
|
1808
|
+
entries = /* @__PURE__ */ new Map();
|
|
1809
|
+
halfLife;
|
|
1810
|
+
coldStart;
|
|
1811
|
+
epsilon;
|
|
1812
|
+
capacity;
|
|
1813
|
+
random;
|
|
1814
|
+
/** @param options - tuning knobs; defaults match the design table. */
|
|
1815
|
+
constructor(options = {}) {
|
|
1816
|
+
this.halfLife = options.halfLife ?? 10;
|
|
1817
|
+
this.coldStart = options.coldStart ?? 8;
|
|
1818
|
+
this.epsilon = options.epsilon ?? .05;
|
|
1819
|
+
this.capacity = options.capacity ?? 2e3;
|
|
1820
|
+
this.random = options.random ?? Math.random;
|
|
1821
|
+
}
|
|
1822
|
+
/** Number of tracked fingerprints. */
|
|
1823
|
+
get size() {
|
|
1824
|
+
return this.entries.size;
|
|
1825
|
+
}
|
|
1826
|
+
/** Read one posterior without decaying it. */
|
|
1827
|
+
get(key) {
|
|
1828
|
+
return this.entries.get(key);
|
|
1829
|
+
}
|
|
1830
|
+
/** Record one terminal outcome for a fingerprint and tier. */
|
|
1831
|
+
record(key, tier, ok, now) {
|
|
1832
|
+
const existing = this.entries.get(key);
|
|
1833
|
+
const base = existing ?? {
|
|
1834
|
+
cheapOK: 0,
|
|
1835
|
+
cheapN: 0,
|
|
1836
|
+
strongOK: 0,
|
|
1837
|
+
strongN: 0,
|
|
1838
|
+
lastSeen: now,
|
|
1839
|
+
observations: 0,
|
|
1840
|
+
probeN: 0
|
|
1841
|
+
};
|
|
1842
|
+
if (existing !== void 0 && existing.observations > 0 && existing.observations % this.halfLife === 0) {
|
|
1843
|
+
base.cheapOK /= 2;
|
|
1844
|
+
base.cheapN /= 2;
|
|
1845
|
+
base.strongOK /= 2;
|
|
1846
|
+
base.strongN /= 2;
|
|
1847
|
+
}
|
|
1848
|
+
if (tier === "cheap") {
|
|
1849
|
+
base.cheapN += 1;
|
|
1850
|
+
if (ok) base.cheapOK += 1;
|
|
1851
|
+
} else {
|
|
1852
|
+
base.strongN += 1;
|
|
1853
|
+
if (ok) base.strongOK += 1;
|
|
1854
|
+
}
|
|
1855
|
+
base.lastSeen = now;
|
|
1856
|
+
base.observations += 1;
|
|
1857
|
+
this.entries.delete(key);
|
|
1858
|
+
this.entries.set(key, base);
|
|
1859
|
+
while (this.entries.size > this.capacity) {
|
|
1860
|
+
const oldest = this.entries.keys().next().value;
|
|
1861
|
+
if (oldest === void 0) break;
|
|
1862
|
+
this.entries.delete(oldest);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
/** Count one exploration probe on a key. */
|
|
1866
|
+
probe(key) {
|
|
1867
|
+
const entry = this.entries.get(key);
|
|
1868
|
+
if (entry !== void 0) entry.probeN += 1;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* The table's opinion about one fingerprint, without the exploration roll.
|
|
1872
|
+
* @param key - the fingerprint.
|
|
1873
|
+
* @returns the base verdict and whether an exploration probe is warranted.
|
|
1874
|
+
*/
|
|
1875
|
+
opinion(key) {
|
|
1876
|
+
const entry = this.entries.get(key);
|
|
1877
|
+
if (entry === void 0) return {
|
|
1878
|
+
verdict: null,
|
|
1879
|
+
explore: false
|
|
1880
|
+
};
|
|
1881
|
+
if (entry.cheapN + entry.strongN < this.coldStart) return {
|
|
1882
|
+
verdict: null,
|
|
1883
|
+
explore: false
|
|
1884
|
+
};
|
|
1885
|
+
if (entry.cheapN === 0) return {
|
|
1886
|
+
verdict: entry.strongN > 0 ? "strong" : null,
|
|
1887
|
+
explore: false
|
|
1888
|
+
};
|
|
1889
|
+
return wilsonLowerBound(entry.cheapOK, entry.cheapN) > .5 ? {
|
|
1890
|
+
verdict: "cheap",
|
|
1891
|
+
explore: true
|
|
1892
|
+
} : {
|
|
1893
|
+
verdict: "strong",
|
|
1894
|
+
explore: false
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* The table's opinion about one fingerprint. The exploration roll is a
|
|
1899
|
+
* decision point, so callers take it once per user input and reuse the
|
|
1900
|
+
* result for every step of that input's turn.
|
|
1901
|
+
* @param key - the fingerprint.
|
|
1902
|
+
* @returns `'strong'`, `'cheap'`, or `null` when the table abstains.
|
|
1903
|
+
*/
|
|
1904
|
+
verdict(key) {
|
|
1905
|
+
const { verdict, explore } = this.opinion(key);
|
|
1906
|
+
if (verdict === "cheap" && explore && this.random() < this.epsilon) {
|
|
1907
|
+
this.probe(key);
|
|
1908
|
+
return "strong";
|
|
1909
|
+
}
|
|
1910
|
+
return verdict;
|
|
1911
|
+
}
|
|
1912
|
+
/** Snapshot every key, newest first, for `/tier status` and diagnostics. */
|
|
1913
|
+
snapshot() {
|
|
1914
|
+
return [...this.entries.entries()].reverse().map(([key, posterior]) => ({
|
|
1915
|
+
key,
|
|
1916
|
+
posterior: { ...posterior }
|
|
1917
|
+
}));
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
//#endregion
|
|
1921
|
+
//#region src/judge.ts
|
|
1922
|
+
/** The label vocabulary the judge is asked to choose from. */
|
|
1923
|
+
const JUDGE_LABELS = [
|
|
1924
|
+
{
|
|
1925
|
+
label: "coding",
|
|
1926
|
+
scenario: "coding"
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
label: "review",
|
|
1930
|
+
scenario: "review"
|
|
1931
|
+
},
|
|
1932
|
+
{
|
|
1933
|
+
label: "planning",
|
|
1934
|
+
scenario: "planning"
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
label: "retrieval",
|
|
1938
|
+
scenario: "retrieval"
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
label: "batch",
|
|
1942
|
+
scenario: "batch"
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
label: "daily",
|
|
1946
|
+
scenario: "daily"
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
label: "longText",
|
|
1950
|
+
scenario: "longText"
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
label: "multimodal",
|
|
1954
|
+
scenario: "multimodal"
|
|
1955
|
+
}
|
|
1956
|
+
];
|
|
1957
|
+
/** Which tier a judged scenario lands on. */
|
|
1958
|
+
const LABEL_TIER = {
|
|
1959
|
+
coding: "cheap",
|
|
1960
|
+
review: "strong",
|
|
1961
|
+
planning: "strong",
|
|
1962
|
+
retrieval: "cheap",
|
|
1963
|
+
batch: "cheap",
|
|
1964
|
+
daily: "cheap",
|
|
1965
|
+
longText: "strong",
|
|
1966
|
+
multimodal: "strong"
|
|
1967
|
+
};
|
|
1968
|
+
/**
|
|
1969
|
+
* Resolve the judge route: the configured model, else the first catalog model
|
|
1970
|
+
* whose id contains `flash` on the cheap tier's provider.
|
|
1971
|
+
* @param ctx - the plugin context (reads `ctx.llm`).
|
|
1972
|
+
* @param config - the resolved configuration.
|
|
1973
|
+
* @returns the route, or undefined when no candidate exists.
|
|
1974
|
+
*/
|
|
1975
|
+
async function resolveJudgeRoute(ctx, config) {
|
|
1976
|
+
const provider = config.tiers.cheap.provider;
|
|
1977
|
+
if (config.intent.judge.model !== "") return {
|
|
1978
|
+
provider,
|
|
1979
|
+
model: config.intent.judge.model
|
|
1980
|
+
};
|
|
1981
|
+
try {
|
|
1982
|
+
const models = await ctx.llm.listModels(provider);
|
|
1983
|
+
const flash = models.find((model) => model.id.toLowerCase().includes("flash"));
|
|
1984
|
+
if (flash !== void 0) return {
|
|
1985
|
+
provider,
|
|
1986
|
+
model: flash.id
|
|
1987
|
+
};
|
|
1988
|
+
const first = models[0];
|
|
1989
|
+
return first === void 0 ? void 0 : {
|
|
1990
|
+
provider,
|
|
1991
|
+
model: first.id
|
|
1992
|
+
};
|
|
1993
|
+
} catch {
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/** Extract the first label that appears in the judge's answer. */
|
|
1998
|
+
function parseJudgeLabel(answer) {
|
|
1999
|
+
const text = answer.trim().toLowerCase();
|
|
2000
|
+
if (text === "") return void 0;
|
|
2001
|
+
for (const entry of JUDGE_LABELS) {
|
|
2002
|
+
const label = entry.label.toLowerCase();
|
|
2003
|
+
if (new RegExp(`(^|[^a-z])${label}([^a-z]|$)`, "u").test(text)) return entry.scenario;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Run one judge call.
|
|
2008
|
+
* @param ctx - the plugin context (reads `ctx.llm`).
|
|
2009
|
+
* @param config - the resolved configuration.
|
|
2010
|
+
* @param text - the newest user message text.
|
|
2011
|
+
* @param signal - the turn's abort signal; the judge adds its own timeout.
|
|
2012
|
+
* @returns the outcome; a failure is reported, never thrown.
|
|
2013
|
+
*/
|
|
2014
|
+
async function runJudge(ctx, config, text, signal) {
|
|
2015
|
+
const route = await resolveJudgeRoute(ctx, config);
|
|
2016
|
+
if (route === void 0) return {
|
|
2017
|
+
ok: false,
|
|
2018
|
+
scenario: void 0,
|
|
2019
|
+
tier: void 0,
|
|
2020
|
+
detail: "no judge model available"
|
|
2021
|
+
};
|
|
2022
|
+
const prompt = [
|
|
2023
|
+
"Classify the user request into exactly one label.",
|
|
2024
|
+
`Labels: ${JUDGE_LABELS.map((entry) => entry.label).join(", ")}`,
|
|
2025
|
+
"Reply with the label only, no punctuation or explanation.",
|
|
2026
|
+
"",
|
|
2027
|
+
`Request: ${text.slice(0, 2e3)}`
|
|
2028
|
+
].join("\n");
|
|
2029
|
+
const timeout = AbortSignal.timeout(config.intent.judge.timeoutMs);
|
|
2030
|
+
const fused = AbortSignal.any([signal, timeout]);
|
|
2031
|
+
const options = {
|
|
2032
|
+
provider: route.provider,
|
|
2033
|
+
model: route.model,
|
|
2034
|
+
messages: [createUserMessage({
|
|
2035
|
+
content: [{
|
|
2036
|
+
type: "text",
|
|
2037
|
+
text: prompt
|
|
2038
|
+
}],
|
|
2039
|
+
source: {
|
|
2040
|
+
kind: "plugin",
|
|
2041
|
+
plugin: "dsh-autotier"
|
|
2042
|
+
}
|
|
2043
|
+
})],
|
|
2044
|
+
temperature: config.intent.judge.temperature,
|
|
2045
|
+
maxTokens: config.intent.judge.maxTokens,
|
|
2046
|
+
signal: fused
|
|
2047
|
+
};
|
|
2048
|
+
const assembler = new BlockAssembler();
|
|
2049
|
+
try {
|
|
2050
|
+
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
return {
|
|
2053
|
+
ok: false,
|
|
2054
|
+
scenario: void 0,
|
|
2055
|
+
tier: void 0,
|
|
2056
|
+
detail: timeout.aborted ? "judge timed out" : `judge failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
const finish = assembler.finish;
|
|
2060
|
+
if (finish.kind === "error" || finish.kind === "aborted") return {
|
|
2061
|
+
ok: false,
|
|
2062
|
+
scenario: void 0,
|
|
2063
|
+
tier: void 0,
|
|
2064
|
+
detail: `judge stream ended with ${finish.kind}`
|
|
2065
|
+
};
|
|
2066
|
+
const scenario = parseJudgeLabel(assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join(" "));
|
|
2067
|
+
if (scenario === void 0) return {
|
|
2068
|
+
ok: false,
|
|
2069
|
+
scenario: void 0,
|
|
2070
|
+
tier: void 0,
|
|
2071
|
+
detail: "judge answer carried no known label"
|
|
2072
|
+
};
|
|
2073
|
+
return {
|
|
2074
|
+
ok: true,
|
|
2075
|
+
scenario,
|
|
2076
|
+
tier: LABEL_TIER[scenario],
|
|
2077
|
+
detail: `judge chose ${scenario}`
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
//#endregion
|
|
2081
|
+
//#region src/routing.ts
|
|
2082
|
+
/** How long third parties have to veto a proposed tier before the turn proceeds. */
|
|
2083
|
+
const VETO_TIMEOUT_MS = 250;
|
|
2084
|
+
/** Extract the plain text of one message's content blocks. */
|
|
2085
|
+
function textOf(content) {
|
|
2086
|
+
return content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
|
|
2087
|
+
}
|
|
2088
|
+
/** The error code of a thrown value, when it carries one. */
|
|
2089
|
+
function codeOf(error) {
|
|
2090
|
+
if (error !== null && typeof error === "object" && "code" in error && typeof error.code === "string") return error.code;
|
|
2091
|
+
return "UNKNOWN";
|
|
2092
|
+
}
|
|
2093
|
+
/** Whether a classified intent is complex enough to open plan mode. */
|
|
2094
|
+
function shouldPlan(intent) {
|
|
2095
|
+
if (intent.tier !== "strong") return false;
|
|
2096
|
+
if (intent.scenario === "review") return false;
|
|
2097
|
+
return intent.scenario === "planning" || intent.signals.score >= 3;
|
|
2098
|
+
}
|
|
2099
|
+
/** The router owns every autotier listener. */
|
|
2100
|
+
var AutotierRouter = class {
|
|
2101
|
+
ctx;
|
|
2102
|
+
service;
|
|
2103
|
+
states;
|
|
2104
|
+
pendingJudges = /* @__PURE__ */ new Set();
|
|
2105
|
+
/** Per-session classifier counters, keyed by session so no agent registry is needed. */
|
|
2106
|
+
counters = /* @__PURE__ */ new WeakMap();
|
|
2107
|
+
/** Router-owned lifetime signal: aborts in-flight judge calls on unload. */
|
|
2108
|
+
lifetime = new AbortController();
|
|
2109
|
+
disposed = false;
|
|
2110
|
+
/**
|
|
2111
|
+
* Register every listener on the plugin fiber.
|
|
2112
|
+
* @param options - the plugin context, the service and the state store.
|
|
2113
|
+
*/
|
|
2114
|
+
constructor(options) {
|
|
2115
|
+
this.ctx = options.ctx;
|
|
2116
|
+
this.service = options.service;
|
|
2117
|
+
this.states = options.states;
|
|
2118
|
+
this.ctx.on("agent/inbox/inserted", (payload) => this.onInboxInserted(payload.agent, payload.message), { prepend: true });
|
|
2119
|
+
this.ctx.on("agent/request", (payload, next) => this.onRequest(payload.agent, payload.turn, payload.step, next), { prepend: true });
|
|
2120
|
+
this.ctx.on("agent/error", (payload) => this.onAgentError(payload.agent, payload.error));
|
|
2121
|
+
this.ctx.on("agent/request-error", (payload, next) => this.onRequestError(payload.agent, payload.provider, payload.failure, next));
|
|
2122
|
+
this.ctx.on("session/event", (session, event) => this.onSessionEvent(session, event));
|
|
2123
|
+
this.ctx.effect(() => () => {
|
|
2124
|
+
this.disposed = true;
|
|
2125
|
+
this.lifetime.abort();
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
/** Pending judge calls (diagnostics and tests). */
|
|
2129
|
+
get judgeCallsInFlight() {
|
|
2130
|
+
return this.pendingJudges.size;
|
|
2131
|
+
}
|
|
2132
|
+
/** The classifier input for one agent. */
|
|
2133
|
+
inputFor(agent, text, hasImage) {
|
|
2134
|
+
const counters = this.counterFor(agent.session);
|
|
2135
|
+
return {
|
|
2136
|
+
text,
|
|
2137
|
+
toolNames: counters.toolNames,
|
|
2138
|
+
hasImage,
|
|
2139
|
+
messageCount: counters.messageCount,
|
|
2140
|
+
cwd: agent.session.header.cwd ?? ""
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
/** The per-session classifier counters, created on first use. */
|
|
2144
|
+
counterFor(session) {
|
|
2145
|
+
let counters = this.counters.get(session);
|
|
2146
|
+
if (counters === void 0) {
|
|
2147
|
+
counters = {
|
|
2148
|
+
toolNames: [],
|
|
2149
|
+
messageCount: 0
|
|
2150
|
+
};
|
|
2151
|
+
this.counters.set(session, counters);
|
|
2152
|
+
}
|
|
2153
|
+
return counters;
|
|
2154
|
+
}
|
|
2155
|
+
/** The routing mode in force for one agent. */
|
|
2156
|
+
modeFor(agent) {
|
|
2157
|
+
return this.states.for(agent).override ?? this.service.config().routingMode;
|
|
2158
|
+
}
|
|
2159
|
+
/** Capture the newest user input, classify it, and start the judge when needed. */
|
|
2160
|
+
onInboxInserted(agent, message) {
|
|
2161
|
+
if (message.source?.kind !== "user") return;
|
|
2162
|
+
const text = textOf(message.content ?? []);
|
|
2163
|
+
const hasImage = (message.content ?? []).some((block) => block.type === "image");
|
|
2164
|
+
const state = this.states.for(agent);
|
|
2165
|
+
const config = this.service.config();
|
|
2166
|
+
const input = this.inputFor(agent, text, hasImage);
|
|
2167
|
+
state.input = input;
|
|
2168
|
+
state.decision = classifyIntent(input, {
|
|
2169
|
+
rules: this.service.rules(),
|
|
2170
|
+
scenarios: config.intent.scenarios
|
|
2171
|
+
});
|
|
2172
|
+
state.verified = false;
|
|
2173
|
+
state.probe = this.service.posteriors().verdict(state.decision.fingerprint) ?? void 0;
|
|
2174
|
+
if (this.modeFor(agent) !== "auto") return;
|
|
2175
|
+
const rule = evaluateRules(this.service.rules(), input);
|
|
2176
|
+
if (shouldPlan(state.decision) && !state.planActive) this.enterPlanMode(agent);
|
|
2177
|
+
if (judgeNeeded(config, state, state.decision, rule, Date.now())) this.startJudge(agent, config, text, state.decision);
|
|
2178
|
+
}
|
|
2179
|
+
/** Fire the judge without blocking the emit dispatch. */
|
|
2180
|
+
startJudge(agent, config, text, local) {
|
|
2181
|
+
const state = this.states.for(agent);
|
|
2182
|
+
state.judge.lastCall = Date.now();
|
|
2183
|
+
const signal = this.lifetime.signal;
|
|
2184
|
+
const generation = local;
|
|
2185
|
+
const task = runJudge(this.ctx, config, text, signal).then((outcome) => {
|
|
2186
|
+
if (this.disposed) return;
|
|
2187
|
+
noteJudgeCall(state, Date.now(), outcome.ok);
|
|
2188
|
+
if (state.decision !== generation) return;
|
|
2189
|
+
if (!outcome.ok || outcome.tier === void 0 || outcome.scenario === void 0) {
|
|
2190
|
+
this.ctx.logger.debug("dsh-autotier: judge abstained (%s); keeping the local verdict", outcome.detail);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
state.decision = {
|
|
2194
|
+
...local,
|
|
2195
|
+
scenario: outcome.scenario,
|
|
2196
|
+
tier: outcome.tier,
|
|
2197
|
+
confidence: .75,
|
|
2198
|
+
reasons: [...local.reasons, outcome.detail]
|
|
2199
|
+
};
|
|
2200
|
+
}).catch((error) => {
|
|
2201
|
+
if (this.disposed) return;
|
|
2202
|
+
noteJudgeCall(state, Date.now(), false);
|
|
2203
|
+
this.ctx.logger.warn("dsh-autotier: judge call failed: %o", error);
|
|
2204
|
+
}).finally(() => {
|
|
2205
|
+
this.pendingJudges.delete(task);
|
|
2206
|
+
});
|
|
2207
|
+
this.pendingJudges.add(task);
|
|
2208
|
+
}
|
|
2209
|
+
/** Open plan mode through the service, or through the log when it is absent. */
|
|
2210
|
+
enterPlanMode(agent) {
|
|
2211
|
+
const planMode = this.ctx.get("planMode");
|
|
2212
|
+
const state = this.states.for(agent);
|
|
2213
|
+
if (planMode !== void 0) try {
|
|
2214
|
+
const outcome = planMode.set(agent, true);
|
|
2215
|
+
if (outcome !== "noop") {
|
|
2216
|
+
state.planActive = true;
|
|
2217
|
+
this.ctx.logger.info("dsh-autotier: plan mode %s for a complex instruction", outcome);
|
|
2218
|
+
}
|
|
2219
|
+
return;
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
this.ctx.logger.warn("dsh-autotier: planMode.set failed (%o); falling back to the session log", error);
|
|
2222
|
+
}
|
|
2223
|
+
try {
|
|
2224
|
+
agent.session.append("plan/mode", { active: true });
|
|
2225
|
+
state.planActive = true;
|
|
2226
|
+
} catch (error) {
|
|
2227
|
+
this.ctx.logger.warn("dsh-autotier: could not open plan mode (%o)", error);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Offer the proposal to third parties on the `autotier/route` serial event.
|
|
2232
|
+
* A listener failure is contained, and a listener that never settles cannot
|
|
2233
|
+
* stall the turn: the race resolves with our own decision after the timeout.
|
|
2234
|
+
* The timer is owned by `ctx.effect`, so unloading clears it (no HMR leak).
|
|
2235
|
+
*/
|
|
2236
|
+
async serialVeto(proposal) {
|
|
2237
|
+
const deadline = new Promise((resolve) => {
|
|
2238
|
+
this.ctx.effect(() => {
|
|
2239
|
+
const timer = setTimeout(() => {
|
|
2240
|
+
resolve(void 0);
|
|
2241
|
+
}, VETO_TIMEOUT_MS);
|
|
2242
|
+
return () => {
|
|
2243
|
+
clearTimeout(timer);
|
|
2244
|
+
};
|
|
2245
|
+
});
|
|
2246
|
+
});
|
|
2247
|
+
const offered = this.ctx.serial("autotier/route", proposal).catch((error) => {
|
|
2248
|
+
this.ctx.logger.warn("dsh-autotier: autotier/route listener failed: %o", error);
|
|
2249
|
+
});
|
|
2250
|
+
return Promise.race([offered, deadline]);
|
|
2251
|
+
}
|
|
2252
|
+
/** The tier landing for one tier, resolving the vision override, an active
|
|
2253
|
+
* fallback record, and the effort-first escalation ladder.
|
|
2254
|
+
*
|
|
2255
|
+
* The ladder is the point of escalation: raise the current model's effort one
|
|
2256
|
+
* step at a time (the KV prefix survives and the official notice stays quiet
|
|
2257
|
+
* for an effort-only change) before paying for a model switch. `rung` counts
|
|
2258
|
+
* how many times escalation has triggered for this agent, so repeated failures
|
|
2259
|
+
* walk the ladder instead of jumping to the strongest landing.
|
|
2260
|
+
*/
|
|
2261
|
+
routeFor(tier, config, intent, state, now, base) {
|
|
2262
|
+
if (intent?.signals !== void 0 && intent.shortCircuit === "image") {
|
|
2263
|
+
const vision = config.tiers.vision;
|
|
2264
|
+
return {
|
|
2265
|
+
provider: vision.provider,
|
|
2266
|
+
model: vision.model
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
const entry = tier === "strong" ? config.tiers.strong : config.tiers.cheap;
|
|
2270
|
+
if (state.fallback !== void 0 && state.fallback.tier === tier && state.fallback.until > now) {
|
|
2271
|
+
const chainEntry = entry.fallback[state.fallback.index];
|
|
2272
|
+
if (chainEntry !== void 0) {
|
|
2273
|
+
const floor = entry.followSession && base?.reasoningEffort !== void 0 ? void 0 : entry.effort;
|
|
2274
|
+
return floor === void 0 ? {
|
|
2275
|
+
provider: chainEntry.provider,
|
|
2276
|
+
model: chainEntry.model
|
|
2277
|
+
} : {
|
|
2278
|
+
provider: chainEntry.provider,
|
|
2279
|
+
model: chainEntry.model,
|
|
2280
|
+
effort: floor
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
if (tier === "strong" && state.escalation !== void 0 && state.escalation.until > now) {
|
|
2285
|
+
const cheapLanding = this.tierRoute("cheap", config);
|
|
2286
|
+
const ladder = escalationLadder(cheapLanding, cheapLanding, this.tierRoute("strong", config));
|
|
2287
|
+
let index = Math.min(Math.max(state.escalation.rung - 1, 0), Math.max(ladder.length - 1, 0));
|
|
2288
|
+
const currentRank = effortRank(base?.reasoningEffort ?? cheapLanding.effort ?? "low");
|
|
2289
|
+
while (index < ladder.length - 1) {
|
|
2290
|
+
const candidate = ladder[index];
|
|
2291
|
+
if ((candidate?.route.effort === void 0 ? EFFORT_LADDER.length : effortRank(candidate.route.effort)) >= currentRank) break;
|
|
2292
|
+
index += 1;
|
|
2293
|
+
}
|
|
2294
|
+
const rung = ladder[index];
|
|
2295
|
+
if (rung !== void 0) return rung.route;
|
|
2296
|
+
}
|
|
2297
|
+
return this.tierRoute(tier, config, base);
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* The configured landing of one tier. `followSession` means the session's own
|
|
2301
|
+
* effort wins when it has one; when it has none, the tier's configured effort
|
|
2302
|
+
* is the floor (an omitted effort would fall through to the adapter default,
|
|
2303
|
+
* which is the strongest level).
|
|
2304
|
+
*/
|
|
2305
|
+
tierRoute(tier, config, base) {
|
|
2306
|
+
const entry = tier === "strong" ? config.tiers.strong : config.tiers.cheap;
|
|
2307
|
+
if (!entry.followSession) return {
|
|
2308
|
+
provider: entry.provider,
|
|
2309
|
+
model: entry.model,
|
|
2310
|
+
effort: entry.effort
|
|
2311
|
+
};
|
|
2312
|
+
if (base?.reasoningEffort === void 0 && entry.effort !== void 0) return {
|
|
2313
|
+
provider: entry.provider,
|
|
2314
|
+
model: entry.model,
|
|
2315
|
+
effort: entry.effort
|
|
2316
|
+
};
|
|
2317
|
+
return {
|
|
2318
|
+
provider: entry.provider,
|
|
2319
|
+
model: entry.model
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
/** Resolve the tier for this step and apply it to the proposed configuration. */
|
|
2323
|
+
async onRequest(agent, turn, step, next) {
|
|
2324
|
+
const base = await next();
|
|
2325
|
+
try {
|
|
2326
|
+
return await this.routeRequest(agent, turn, step, base);
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
this.ctx.logger.error("dsh-autotier: routing failed, using the session configuration: %o", error);
|
|
2329
|
+
return base;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
/** The routing body, separated so one try/catch guards the whole seam. */
|
|
2333
|
+
async routeRequest(agent, turn, step, base) {
|
|
2334
|
+
const mode = this.modeFor(agent);
|
|
2335
|
+
if (mode === "off" || mode === "delegated") return base;
|
|
2336
|
+
const state = this.states.for(agent);
|
|
2337
|
+
const config = this.service.config();
|
|
2338
|
+
const now = Date.now();
|
|
2339
|
+
clearExpiredEscalation(state, now);
|
|
2340
|
+
const intent = state.decision;
|
|
2341
|
+
if (intent === void 0) return base;
|
|
2342
|
+
let decision = decideTier({
|
|
2343
|
+
config,
|
|
2344
|
+
state,
|
|
2345
|
+
intent,
|
|
2346
|
+
rule: state.input === void 0 ? null : evaluateRules(this.service.rules(), state.input),
|
|
2347
|
+
override: state.override,
|
|
2348
|
+
now
|
|
2349
|
+
});
|
|
2350
|
+
if ((decision.source === "judge" || decision.source === "default") && attemptBandApplies(config, intent) && !state.verified && !escalationActive(state, now)) decision = {
|
|
2351
|
+
tier: "cheap",
|
|
2352
|
+
source: "default",
|
|
2353
|
+
reason: `${intent.reasons.join("; ")}; attempt-first band`,
|
|
2354
|
+
confidence: intent.confidence
|
|
2355
|
+
};
|
|
2356
|
+
if (state.reviewOwedFor !== void 0 && state.reviewOwedFor === intent.fingerprint && !state.verified) {
|
|
2357
|
+
state.verified = true;
|
|
2358
|
+
state.reviewOwedFor = void 0;
|
|
2359
|
+
decision = {
|
|
2360
|
+
tier: "strong",
|
|
2361
|
+
source: "escalation",
|
|
2362
|
+
reason: "attempt-first strong review",
|
|
2363
|
+
confidence: 1
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
const proposal = {
|
|
2367
|
+
agent,
|
|
2368
|
+
turn,
|
|
2369
|
+
step,
|
|
2370
|
+
tier: decision.tier,
|
|
2371
|
+
source: decision.source,
|
|
2372
|
+
reason: decision.reason,
|
|
2373
|
+
confidence: decision.confidence
|
|
2374
|
+
};
|
|
2375
|
+
const veto = await this.serialVeto(proposal);
|
|
2376
|
+
const tier = veto?.tier ?? decision.tier;
|
|
2377
|
+
const source = veto === void 0 || veto === null ? decision.source : "manual";
|
|
2378
|
+
const reason = veto === void 0 || veto === null ? decision.reason : `veto: ${veto.reason}`;
|
|
2379
|
+
const route = this.routeFor(tier, config, intent, state, now, base);
|
|
2380
|
+
const applied = resolveRoute(base, route);
|
|
2381
|
+
if (state.appliedTier !== tier) {
|
|
2382
|
+
const from = state.appliedTier;
|
|
2383
|
+
state.appliedTier = tier;
|
|
2384
|
+
state.appliedSource = source;
|
|
2385
|
+
this.ctx.emit("autotier/tier-changed", {
|
|
2386
|
+
agent,
|
|
2387
|
+
from,
|
|
2388
|
+
to: tier,
|
|
2389
|
+
source,
|
|
2390
|
+
reason,
|
|
2391
|
+
route
|
|
2392
|
+
});
|
|
2393
|
+
} else state.appliedSource = source;
|
|
2394
|
+
this.ctx.logger.debug("dsh-autotier: turn=%d step=%d tier=%s source=%s (%s)", turn, step, tier, source, reason);
|
|
2395
|
+
return applied;
|
|
2396
|
+
}
|
|
2397
|
+
/** Count failures and escalate on the configured signature recurrence. */
|
|
2398
|
+
onAgentError(agent, error) {
|
|
2399
|
+
const mode = this.modeFor(agent);
|
|
2400
|
+
if (mode === "off" || mode === "delegated") return;
|
|
2401
|
+
const state = this.states.for(agent);
|
|
2402
|
+
const config = this.service.config();
|
|
2403
|
+
const signature = `${codeOf(error)}|${state.decision?.fingerprint ?? ""}`;
|
|
2404
|
+
const now = Date.now();
|
|
2405
|
+
if (state.decision !== void 0 && attemptBandApplies(config, state.decision) && !state.verified) state.reviewOwedFor = state.decision.fingerprint;
|
|
2406
|
+
const alreadyEscalated = escalationActive(state, now);
|
|
2407
|
+
if ((state.appliedTier ?? "cheap") !== "cheap" && !alreadyEscalated) return;
|
|
2408
|
+
if (noteFailure(state, signature, config, now) && !alreadyEscalated) {
|
|
2409
|
+
const tier = this.routeFor("strong", config, state.decision, state, now);
|
|
2410
|
+
this.ctx.logger.warn("dsh-autotier: escalating after %d recurring failure(s) (%s) -> %s/%s", state.escalation?.count ?? 0, signature, tier.provider, tier.model);
|
|
2411
|
+
this.ctx.emit("autotier/tier-changed", {
|
|
2412
|
+
agent,
|
|
2413
|
+
from: state.appliedTier,
|
|
2414
|
+
to: "strong",
|
|
2415
|
+
source: "escalation",
|
|
2416
|
+
reason: signature,
|
|
2417
|
+
route: tier
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
if (state.decision !== void 0) this.service.posteriors().record(state.decision.fingerprint, state.appliedTier ?? "cheap", false, now);
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Walk the tier's fallback chain. Permanent codes switch immediately;
|
|
2424
|
+
* transient codes wait for `dsh-llm-retry` to exhaust its own retries first
|
|
2425
|
+
* (this listener is registered after it on purpose).
|
|
2426
|
+
*/
|
|
2427
|
+
async onRequestError(agent, provider, failure, next) {
|
|
2428
|
+
const mode = this.modeFor(agent);
|
|
2429
|
+
if (mode === "off" || mode === "delegated") return next();
|
|
2430
|
+
const config = this.service.config();
|
|
2431
|
+
const verdict = classifyFallback(failure);
|
|
2432
|
+
if (verdict === "ignore" || verdict === "unknown") return next();
|
|
2433
|
+
const state = this.states.for(agent);
|
|
2434
|
+
const tier = escalationActive(state, Date.now()) ? "strong" : state.appliedTier ?? "cheap";
|
|
2435
|
+
const chain = tier === "strong" ? config.tiers.strong.fallback : config.tiers.cheap.fallback;
|
|
2436
|
+
if (chain.length === 0) return next();
|
|
2437
|
+
if (verdict === "transient") {
|
|
2438
|
+
const downstream = await next();
|
|
2439
|
+
if (downstream !== void 0) return downstream;
|
|
2440
|
+
}
|
|
2441
|
+
const now = Date.now();
|
|
2442
|
+
if (noteFallback(state, tier, chain.length, config, now)) {
|
|
2443
|
+
this.ctx.logger.warn("dsh-autotier: provider \"%s\" failed with %s; switching to fallback entry %d of the %s tier", provider, String(typeof failure.code === "string" ? failure.code : `status ${String(failure.status ?? "?")}`), (state.fallback?.index ?? 0) + 1, tier);
|
|
2444
|
+
return { kind: "retry" };
|
|
2445
|
+
}
|
|
2446
|
+
return next();
|
|
2447
|
+
}
|
|
2448
|
+
/** Maintain the classifier counters and the plan-mode fallback fold. */
|
|
2449
|
+
onSessionEvent(session, event) {
|
|
2450
|
+
const counters = this.counterFor(session);
|
|
2451
|
+
if (event.type === "user/message") {
|
|
2452
|
+
counters.messageCount += 1;
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
if (event.type === "tool/call") {
|
|
2456
|
+
const name = event.data.name;
|
|
2457
|
+
if (typeof name === "string" && !counters.toolNames.includes(name)) counters.toolNames.push(name);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (event.type === "plan/mode") {
|
|
2461
|
+
const agent = this.agentOf(session);
|
|
2462
|
+
if (agent !== void 0) this.states.for(agent).planActive = event.data.active === true;
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (event.type === "turn/end") {
|
|
2466
|
+
const reason = event.data.reason?.kind;
|
|
2467
|
+
const agent = this.agentOf(session);
|
|
2468
|
+
if (agent === void 0) return;
|
|
2469
|
+
const state = this.states.for(agent);
|
|
2470
|
+
if (reason === "completed" && state.decision !== void 0) this.service.posteriors().record(state.decision.fingerprint, state.appliedTier ?? "cheap", true, Date.now());
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
/** Resolve the agent that owns one session, when the registry is reachable. */
|
|
2474
|
+
agentOf(session) {
|
|
2475
|
+
const agents = this.ctx.get("agents");
|
|
2476
|
+
if (agents === void 0) return void 0;
|
|
2477
|
+
try {
|
|
2478
|
+
return agents.get(session.id);
|
|
2479
|
+
} catch {
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
//#endregion
|
|
2485
|
+
//#region src/service.ts
|
|
2486
|
+
/**
|
|
2487
|
+
* `ctx.autotier`: the Service Provider for autotier's public read surface. The
|
|
2488
|
+
* service owns the live resolved configuration, the compiled rule table and the
|
|
2489
|
+
* fingerprint posteriors, and serves the status snapshot that `/tier status`,
|
|
2490
|
+
* the `tier_status` tool and any third-party consumer read. Routing decisions
|
|
2491
|
+
* themselves live in `policy.ts`/`routing.ts`; this class is the stable contract
|
|
2492
|
+
* other plugins may depend on.
|
|
2493
|
+
* @module dsh-autotier/service
|
|
2494
|
+
*/
|
|
2495
|
+
/** Project one resolved tier config onto the public route shape. */
|
|
2496
|
+
function routeOf(tier) {
|
|
2497
|
+
if (tier.followSession) return {
|
|
2498
|
+
provider: tier.provider,
|
|
2499
|
+
model: tier.model
|
|
2500
|
+
};
|
|
2501
|
+
return {
|
|
2502
|
+
provider: tier.provider,
|
|
2503
|
+
model: tier.model,
|
|
2504
|
+
effort: tier.effort
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
/**
|
|
2508
|
+
* Service Provider for `ctx.autotier`. Registration rides the owning fiber: the
|
|
2509
|
+
* plugin unloading removes the service with every listener it owns.
|
|
2510
|
+
*/
|
|
2511
|
+
var AutotierService = class extends Service {
|
|
2512
|
+
scope;
|
|
2513
|
+
posteriorTable = new PosteriorTable();
|
|
2514
|
+
resolved;
|
|
2515
|
+
compiled;
|
|
2516
|
+
/**
|
|
2517
|
+
* Register the service as `ctx.autotier` and start following the settings
|
|
2518
|
+
* namespace.
|
|
2519
|
+
* @param ctx - the owning plugin context.
|
|
2520
|
+
* @param options - the live settings scope and the mount-time configuration.
|
|
2521
|
+
*/
|
|
2522
|
+
constructor(ctx, options) {
|
|
2523
|
+
super(ctx, "autotier");
|
|
2524
|
+
this.scope = options.scope;
|
|
2525
|
+
this.resolved = options.config;
|
|
2526
|
+
this.compiled = compileRules(options.config.intent.rules);
|
|
2527
|
+
ctx.effect(() => this.scope.watch((next) => {
|
|
2528
|
+
this.resolved = resolveConfig(next);
|
|
2529
|
+
this.compiled = compileRules(this.resolved.intent.rules);
|
|
2530
|
+
}));
|
|
2531
|
+
}
|
|
2532
|
+
/** The live resolved configuration. */
|
|
2533
|
+
config() {
|
|
2534
|
+
return this.resolved;
|
|
2535
|
+
}
|
|
2536
|
+
/** The compiled declarative rule table, ordered by descending priority. */
|
|
2537
|
+
rules() {
|
|
2538
|
+
return this.compiled;
|
|
2539
|
+
}
|
|
2540
|
+
/** The per-fingerprint win-rate posteriors. */
|
|
2541
|
+
posteriors() {
|
|
2542
|
+
return this.posteriorTable;
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* The registered provider/model catalog, as the minimal serializable subset a
|
|
2546
|
+
* configuration UI needs. Never hardcoded: it reads the live `ctx.llm`
|
|
2547
|
+
* registry, so a model the adapter does not advertise cannot be selected.
|
|
2548
|
+
* @returns one entry per registered provider with its models.
|
|
2549
|
+
*/
|
|
2550
|
+
async catalog() {
|
|
2551
|
+
const entries = [];
|
|
2552
|
+
for (const provider of this.ctx.llm.listProviders()) {
|
|
2553
|
+
let models = [];
|
|
2554
|
+
try {
|
|
2555
|
+
models = await this.ctx.llm.listModels(provider.id);
|
|
2556
|
+
} catch {
|
|
2557
|
+
models = [];
|
|
2558
|
+
}
|
|
2559
|
+
entries.push({
|
|
2560
|
+
provider: provider.id,
|
|
2561
|
+
models: models.map((model) => ({
|
|
2562
|
+
id: model.id,
|
|
2563
|
+
name: model.name,
|
|
2564
|
+
inputModalities: model.inputModalities ?? ["text"]
|
|
2565
|
+
}))
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
return entries;
|
|
2569
|
+
}
|
|
2570
|
+
/** Read-only status snapshot. */
|
|
2571
|
+
status() {
|
|
2572
|
+
const config = this.resolved;
|
|
2573
|
+
return {
|
|
2574
|
+
mode: config.routingMode,
|
|
2575
|
+
tiers: {
|
|
2576
|
+
strong: routeOf(config.tiers.strong),
|
|
2577
|
+
cheap: routeOf(config.tiers.cheap),
|
|
2578
|
+
vision: {
|
|
2579
|
+
provider: config.tiers.vision.provider,
|
|
2580
|
+
model: config.tiers.vision.model
|
|
2581
|
+
}
|
|
2582
|
+
},
|
|
2583
|
+
guard: {
|
|
2584
|
+
enabled: config.guard.enabled,
|
|
2585
|
+
tiers: config.guard.tiers
|
|
2586
|
+
},
|
|
2587
|
+
escalation: {
|
|
2588
|
+
threshold: config.escalation.threshold,
|
|
2589
|
+
windowMs: config.escalation.windowMs,
|
|
2590
|
+
ttlMs: config.escalation.ttlMs,
|
|
2591
|
+
fallbackTtlMs: config.escalation.fallbackTtlMs,
|
|
2592
|
+
signature: config.escalation.signature
|
|
2593
|
+
}
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
};
|
|
2597
|
+
//#endregion
|
|
2598
|
+
//#region src/state.ts
|
|
2599
|
+
/** The projection key autotier owns. */
|
|
2600
|
+
const TIER_PROJECTION_KEY = "autotier";
|
|
2601
|
+
/** Minimal zod-compatible schemas for the projection state and wire view. */
|
|
2602
|
+
const projectionSchema = { parse(value) {
|
|
2603
|
+
const record = value ?? {};
|
|
2604
|
+
return {
|
|
2605
|
+
provider: typeof record.provider === "string" ? record.provider : "",
|
|
2606
|
+
model: typeof record.model === "string" ? record.model : "",
|
|
2607
|
+
effort: typeof record.effort === "string" ? record.effort : "",
|
|
2608
|
+
plan: record.plan === true
|
|
2609
|
+
};
|
|
2610
|
+
} };
|
|
2611
|
+
/**
|
|
2612
|
+
* Register the tier projection when the registry is composed.
|
|
2613
|
+
* @param ctx - the plugin context; the registration rides its fiber.
|
|
2614
|
+
* @returns the registration disposer, or undefined when the registry is absent.
|
|
2615
|
+
*/
|
|
2616
|
+
function registerTierProjection(ctx) {
|
|
2617
|
+
const registry = ctx.get("sessionProjections");
|
|
2618
|
+
if (registry === void 0) {
|
|
2619
|
+
ctx.logger.warn("dsh-autotier: sessionProjections is absent; the replayable tier projection is disabled");
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
return registry.register({
|
|
2623
|
+
key: TIER_PROJECTION_KEY,
|
|
2624
|
+
stateVersion: 1,
|
|
2625
|
+
stateSchema: projectionSchema,
|
|
2626
|
+
init: () => ({
|
|
2627
|
+
provider: "",
|
|
2628
|
+
model: "",
|
|
2629
|
+
effort: "",
|
|
2630
|
+
plan: false
|
|
2631
|
+
}),
|
|
2632
|
+
apply: (state, event) => {
|
|
2633
|
+
if (event.type === "plan/mode") {
|
|
2634
|
+
const plan = event.data.active === true;
|
|
2635
|
+
return plan === state.plan ? state : {
|
|
2636
|
+
...state,
|
|
2637
|
+
plan
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
if (event.type !== "request/header") return state;
|
|
2641
|
+
const config = event.data.header?.config;
|
|
2642
|
+
if (config === void 0) return state;
|
|
2643
|
+
const next = {
|
|
2644
|
+
provider: typeof config.provider === "string" ? config.provider : "",
|
|
2645
|
+
model: typeof config.model === "string" ? config.model : "",
|
|
2646
|
+
effort: typeof config.reasoningEffort === "string" ? config.reasoningEffort : "",
|
|
2647
|
+
plan: state.plan
|
|
2648
|
+
};
|
|
2649
|
+
return next.provider === state.provider && next.model === state.model && next.effort === state.effort && next.plan === state.plan ? state : next;
|
|
2650
|
+
},
|
|
2651
|
+
wire: {
|
|
2652
|
+
viewSchema: projectionSchema,
|
|
2653
|
+
view: (state) => state
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
/** The per-agent runtime state store. */
|
|
2658
|
+
var AgentStateStore = class {
|
|
2659
|
+
states = /* @__PURE__ */ new WeakMap();
|
|
2660
|
+
/** The state for one agent, created on first use. */
|
|
2661
|
+
for(agent) {
|
|
2662
|
+
let state = this.states.get(agent);
|
|
2663
|
+
if (state === void 0) {
|
|
2664
|
+
state = createRouteState();
|
|
2665
|
+
this.states.set(agent, state);
|
|
2666
|
+
}
|
|
2667
|
+
return state;
|
|
2668
|
+
}
|
|
2669
|
+
/** Whether one agent already has state (diagnostics). */
|
|
2670
|
+
has(agent) {
|
|
2671
|
+
return this.states.has(agent);
|
|
2672
|
+
}
|
|
2673
|
+
};
|
|
2674
|
+
//#endregion
|
|
2675
|
+
//#region src/tools.ts
|
|
2676
|
+
/** Render one canonical value as a single text block. */
|
|
2677
|
+
function textBlock(value) {
|
|
2678
|
+
return [{
|
|
2679
|
+
type: "text",
|
|
2680
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
2681
|
+
}];
|
|
2682
|
+
}
|
|
2683
|
+
/** Build the `tier_status` tool. */
|
|
2684
|
+
function tierStatusTool({ service, states }) {
|
|
2685
|
+
return defineTool({
|
|
2686
|
+
name: "tier_status",
|
|
2687
|
+
description: "Report the live dsh-autotier routing state: the configured tier landings, the effective mode for this session, the last intent classification, and whether a failure escalation or fallback is in force. Read-only.",
|
|
2688
|
+
parameters: {},
|
|
2689
|
+
output: {
|
|
2690
|
+
schema: {
|
|
2691
|
+
type: "object",
|
|
2692
|
+
additionalProperties: false,
|
|
2693
|
+
properties: {
|
|
2694
|
+
mode: { type: "string" },
|
|
2695
|
+
strong: { type: "string" },
|
|
2696
|
+
cheap: { type: "string" },
|
|
2697
|
+
vision: { type: "string" },
|
|
2698
|
+
guardEnabled: { type: "boolean" },
|
|
2699
|
+
appliedTier: { type: "string" },
|
|
2700
|
+
lastScenario: { type: "string" },
|
|
2701
|
+
lastConfidence: { type: "number" },
|
|
2702
|
+
escalated: { type: "boolean" },
|
|
2703
|
+
planActive: { type: "boolean" },
|
|
2704
|
+
guardDenials: { type: "integer" },
|
|
2705
|
+
lastDenialRule: { type: "string" }
|
|
2706
|
+
}
|
|
2707
|
+
},
|
|
2708
|
+
render: (_args, value) => textBlock(value)
|
|
2709
|
+
},
|
|
2710
|
+
execute: async (_args, exec) => {
|
|
2711
|
+
const agent = exec.agent;
|
|
2712
|
+
const state = agent === void 0 ? void 0 : states.for(agent);
|
|
2713
|
+
const status = service.status();
|
|
2714
|
+
return {
|
|
2715
|
+
mode: state?.override ?? status.mode,
|
|
2716
|
+
strong: `${status.tiers.strong.provider}/${status.tiers.strong.model}${status.tiers.strong.effort === void 0 ? "" : `@${status.tiers.strong.effort}`}`,
|
|
2717
|
+
cheap: `${status.tiers.cheap.provider}/${status.tiers.cheap.model}${status.tiers.cheap.effort === void 0 ? "" : `@${status.tiers.cheap.effort}`}`,
|
|
2718
|
+
vision: `${status.tiers.vision.provider}/${status.tiers.vision.model}`,
|
|
2719
|
+
guardEnabled: status.guard.enabled,
|
|
2720
|
+
appliedTier: state?.appliedTier ?? "",
|
|
2721
|
+
lastScenario: state?.decision?.scenario ?? "",
|
|
2722
|
+
lastConfidence: state?.decision?.confidence ?? 0,
|
|
2723
|
+
escalated: state === void 0 ? false : escalationActive(state, Date.now()),
|
|
2724
|
+
planActive: state?.planActive ?? false,
|
|
2725
|
+
guardDenials: state?.denials ?? 0,
|
|
2726
|
+
lastDenialRule: state?.lastDenial ?? ""
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
/** Build the `tier_route` tool. */
|
|
2732
|
+
function tierRouteTool({ service }) {
|
|
2733
|
+
return defineTool({
|
|
2734
|
+
name: "tier_route",
|
|
2735
|
+
description: "Dry-run the intent classifier on one instruction and report which tier it would use, without sending a model request. Read-only.",
|
|
2736
|
+
parameters: { text: {
|
|
2737
|
+
type: "string",
|
|
2738
|
+
description: "The instruction to classify.",
|
|
2739
|
+
required: true
|
|
2740
|
+
} },
|
|
2741
|
+
output: {
|
|
2742
|
+
schema: {
|
|
2743
|
+
type: "object",
|
|
2744
|
+
additionalProperties: false,
|
|
2745
|
+
properties: {
|
|
2746
|
+
tier: { type: "string" },
|
|
2747
|
+
scenario: { type: "string" },
|
|
2748
|
+
confidence: { type: "number" },
|
|
2749
|
+
fingerprint: { type: "string" },
|
|
2750
|
+
reason: { type: "string" }
|
|
2751
|
+
}
|
|
2752
|
+
},
|
|
2753
|
+
render: (_args, value) => textBlock(value)
|
|
2754
|
+
},
|
|
2755
|
+
execute: async (args) => {
|
|
2756
|
+
const config = service.config();
|
|
2757
|
+
const result = classifyIntent({
|
|
2758
|
+
text: args.text,
|
|
2759
|
+
toolNames: [],
|
|
2760
|
+
hasImage: false,
|
|
2761
|
+
messageCount: 0,
|
|
2762
|
+
cwd: ""
|
|
2763
|
+
}, {
|
|
2764
|
+
rules: service.rules(),
|
|
2765
|
+
scenarios: config.intent.scenarios
|
|
2766
|
+
});
|
|
2767
|
+
return {
|
|
2768
|
+
tier: result.tier,
|
|
2769
|
+
scenario: result.scenario,
|
|
2770
|
+
confidence: result.confidence,
|
|
2771
|
+
fingerprint: result.fingerprint,
|
|
2772
|
+
reason: result.reasons.join("; ")
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Register both tools on the plugin fiber.
|
|
2779
|
+
* @param ctx - the plugin context (must have `tools`).
|
|
2780
|
+
* @param services - the service and the state store.
|
|
2781
|
+
*/
|
|
2782
|
+
function registerTierTools(ctx, services) {
|
|
2783
|
+
ctx.tools.register(tierStatusTool(services));
|
|
2784
|
+
ctx.tools.register(tierRouteTool(services));
|
|
2785
|
+
}
|
|
2786
|
+
//#endregion
|
|
2787
|
+
//#region src/index.ts
|
|
2788
|
+
/** The cordis.yml row id and the plugin name must match. */
|
|
2789
|
+
const name = "dsh-autotier";
|
|
2790
|
+
/**
|
|
2791
|
+
* Hard service dependencies. `sessions` is plural — the service name really is
|
|
2792
|
+
* `sessions` (`packages/core/session/src/index.ts` registers `super(ctx,
|
|
2793
|
+
* 'sessions')`); declaring a non-existent name would leave this plugin PENDING
|
|
2794
|
+
* forever. Every other capability (`agents`, `subagents`, `systemPrompt`,
|
|
2795
|
+
* `planMode`, `sessionProjections`, `sandboxPolicy`) is read with `ctx.get()`
|
|
2796
|
+
* and degrades when absent.
|
|
2797
|
+
*/
|
|
2798
|
+
const inject = [
|
|
2799
|
+
"settings",
|
|
2800
|
+
"llm",
|
|
2801
|
+
"tools",
|
|
2802
|
+
"commands",
|
|
2803
|
+
"sessions"
|
|
2804
|
+
];
|
|
2805
|
+
/**
|
|
2806
|
+
* Mount the plugin: judge the configuration, register the `autotier` settings
|
|
2807
|
+
* namespace, publish the `ctx.autotier` service, and wire the routing listeners,
|
|
2808
|
+
* the `/tier` command and the two read-only tools.
|
|
2809
|
+
*
|
|
2810
|
+
* @param ctx - the plugin context.
|
|
2811
|
+
* @param config - the raw row configuration; every field is optional.
|
|
2812
|
+
* @throws {Error} when the configuration fails the cross-field judgement.
|
|
2813
|
+
*/
|
|
2814
|
+
function apply(ctx, config = {}) {
|
|
2815
|
+
const resolved = resolveConfig(config);
|
|
2816
|
+
const service = new AutotierService(ctx, {
|
|
2817
|
+
scope: ctx.settings.register("autotier", Config, {
|
|
2818
|
+
base: config,
|
|
2819
|
+
applies: "live",
|
|
2820
|
+
validate: (value) => {
|
|
2821
|
+
validateConfig(value);
|
|
2822
|
+
}
|
|
2823
|
+
}),
|
|
2824
|
+
config: resolved
|
|
2825
|
+
});
|
|
2826
|
+
registerTierProjection(ctx);
|
|
2827
|
+
const states = new AgentStateStore();
|
|
2828
|
+
new AutotierRouter({
|
|
2829
|
+
ctx,
|
|
2830
|
+
service,
|
|
2831
|
+
states
|
|
2832
|
+
});
|
|
2833
|
+
registerGuardHook({
|
|
2834
|
+
ctx,
|
|
2835
|
+
service,
|
|
2836
|
+
states
|
|
2837
|
+
});
|
|
2838
|
+
registerTierCommand(ctx, service, states);
|
|
2839
|
+
registerTierTools(ctx, {
|
|
2840
|
+
service,
|
|
2841
|
+
states
|
|
2842
|
+
});
|
|
2843
|
+
if (resolved.guard.interopDefend === "auto") ctx.logger.info("dsh-autotier: guard runs alongside dsh-defend when installed (guard.interopDefend=auto)");
|
|
2844
|
+
const status = service.status();
|
|
2845
|
+
ctx.logger.info("dsh-autotier: mode=%s strong=%s/%s cheap=%s/%s guard=%s", status.mode, status.tiers.strong.provider, status.tiers.strong.model, status.tiers.cheap.provider, status.tiers.cheap.model, status.guard.enabled ? "on" : "off");
|
|
2846
|
+
}
|
|
2847
|
+
//#endregion
|
|
2848
|
+
export { AgentStateStore, AutotierRouter, AutotierService, Config, EFFORT_IDS, HIGH_IMPACT_COMMAND_RULES, HIGH_IMPACT_PATH_RULES, JUDGE_LABELS, PosteriorTable, ROUTING_MODES, SCENARIOS, TIER_IDS, TIER_PROJECTION_KEY, advanceFallback, apply, attemptBandApplies, classifyFallback, classifyIntent, compileRules, computeSignals, createRouteState, decideTier, effortRank, escalationActive, escalationLadder, evaluateRules, evaluateToolCall, fallbackActive, fingerprintOf, inject, isCredentialPath, judgeNeeded, matchCommand, matchPath, name, nextEffortStep, noteFailure, noteFallback, noteJudgeCall, parseJudgeLabel, redactSnippet, registerGuardHook, registerTierProjection, resolveConfig, resolveJudgeRoute, resolveRoute, routeEquals, runJudge, validateConfig, wilsonLowerBound };
|