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.
Files changed (60) hide show
  1. package/AGENTS.md +93 -0
  2. package/CHANGELOG.md +85 -0
  3. package/LICENSE +201 -0
  4. package/README.es.md +247 -0
  5. package/README.hi.md +241 -0
  6. package/README.md +245 -0
  7. package/README.pt.md +246 -0
  8. package/README.zh.md +221 -0
  9. package/SECURITY.md +55 -0
  10. package/THIRD_PARTY_NOTICES.md +63 -0
  11. package/cordis.patch.yml +125 -0
  12. package/docs/preset-row.md +61 -0
  13. package/docs/supporting-lanes.md +45 -0
  14. package/lib/index.js +2848 -0
  15. package/lib/types/command.d.ts +17 -0
  16. package/lib/types/command.d.ts.map +1 -0
  17. package/lib/types/config.d.ts +94 -0
  18. package/lib/types/config.d.ts.map +1 -0
  19. package/lib/types/guard-rules.d.ts +97 -0
  20. package/lib/types/guard-rules.d.ts.map +1 -0
  21. package/lib/types/guard.d.ts +70 -0
  22. package/lib/types/guard.d.ts.map +1 -0
  23. package/lib/types/index.d.ts +60 -0
  24. package/lib/types/index.d.ts.map +1 -0
  25. package/lib/types/intent.d.ts +179 -0
  26. package/lib/types/intent.d.ts.map +1 -0
  27. package/lib/types/judge.d.ts +50 -0
  28. package/lib/types/judge.d.ts.map +1 -0
  29. package/lib/types/policy.d.ts +109 -0
  30. package/lib/types/policy.d.ts.map +1 -0
  31. package/lib/types/routing.d.ts +135 -0
  32. package/lib/types/routing.d.ts.map +1 -0
  33. package/lib/types/schema.d.ts +134 -0
  34. package/lib/types/schema.d.ts.map +1 -0
  35. package/lib/types/service.d.ts +67 -0
  36. package/lib/types/service.d.ts.map +1 -0
  37. package/lib/types/state.d.ts +46 -0
  38. package/lib/types/state.d.ts.map +1 -0
  39. package/lib/types/tiers.d.ts +103 -0
  40. package/lib/types/tiers.d.ts.map +1 -0
  41. package/lib/types/tools.d.ts +26 -0
  42. package/lib/types/tools.d.ts.map +1 -0
  43. package/lib/types/types.d.ts +96 -0
  44. package/lib/types/types.d.ts.map +1 -0
  45. package/package.json +179 -0
  46. package/src/command.ts +73 -0
  47. package/src/config.ts +358 -0
  48. package/src/guard-rules.ts +303 -0
  49. package/src/guard.ts +285 -0
  50. package/src/index.ts +149 -0
  51. package/src/intent.ts +484 -0
  52. package/src/judge.ts +150 -0
  53. package/src/policy.ts +246 -0
  54. package/src/routing.ts +575 -0
  55. package/src/schema.ts +295 -0
  56. package/src/service.ts +131 -0
  57. package/src/state.ts +134 -0
  58. package/src/tiers.ts +212 -0
  59. package/src/tools.ts +128 -0
  60. package/src/types.ts +120 -0
package/src/policy.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The routing decision state machine: it turns one classified intent plus the
3
+ * per-agent runtime state into the tier to apply, and owns the TTL semantics of
4
+ * escalation, fallback and the judge cooldown. Pure and synchronous — the
5
+ * asynchronous judge call lives in `judge.ts`.
6
+ *
7
+ * @module dsh-autotier/policy
8
+ */
9
+
10
+ import type { ResolvedConfig } from './config.ts'
11
+ import type { IntentInput, IntentResult, RuleHit } from './intent.ts'
12
+ import { advanceFallback, fallbackActive, type FallbackRecord } from './tiers.ts'
13
+ import type { RouteSource, RoutingMode, TierId } from './types.ts'
14
+
15
+ /** Mutable per-agent routing state. Never persisted: it is per-process runtime. */
16
+ export interface RouteState {
17
+ /** The decision for the newest user input, reused by every step of its turn. */
18
+ decision: IntentResult | undefined
19
+ /** The classifier input the decision was computed from. */
20
+ input: IntentInput | undefined
21
+ /** Session-level override set by `/tier`; `undefined` = follow the configuration. */
22
+ override: RoutingMode | undefined
23
+ /** The tier actually applied to the last request (hysteresis anchor). */
24
+ appliedTier: TierId | undefined
25
+ /** The source that produced `appliedTier`; hysteresis only damps classifier-driven changes. */
26
+ appliedSource: RouteSource | undefined
27
+ /** Plan mode as last observed. */
28
+ planActive: boolean
29
+ /** Failure escalation. */
30
+ escalation: { count: number; signature: string; until: number; rung: number; lastAt: number } | undefined
31
+ /** Fallback-chain position (scoped to one tier). */
32
+ fallback: FallbackRecord | undefined
33
+ /** Judge resilience. */
34
+ judge: { failures: number; lastCall: number }
35
+ /** Attempt-first band: the strong review has already run for this input. */
36
+ verified: boolean
37
+ /** The fingerprint that owes one strong review after a cheap-run signal. */
38
+ reviewOwedFor: string | undefined
39
+ /** The posterior exploration roll, taken once per user input. */
40
+ probe: 'strong' | 'cheap' | undefined
41
+ /** How many calls the guard denied for this agent. */
42
+ denials: number
43
+ /** The last rule the guard fired, for `/tier status`. */
44
+ lastDenial: string
45
+ }
46
+
47
+ /** A fresh per-agent state. */
48
+ export function createRouteState(): RouteState {
49
+ return {
50
+ decision: undefined,
51
+ input: undefined,
52
+ override: undefined,
53
+ appliedTier: undefined,
54
+ appliedSource: undefined,
55
+ planActive: false,
56
+ escalation: undefined,
57
+ fallback: undefined,
58
+ judge: { failures: 0, lastCall: 0 },
59
+ verified: false,
60
+ reviewOwedFor: undefined,
61
+ probe: undefined,
62
+ denials: 0,
63
+ lastDenial: '',
64
+ }
65
+ }
66
+
67
+ /** One routing decision with provenance. */
68
+ export interface Decision {
69
+ readonly tier: TierId
70
+ readonly source: RouteSource
71
+ readonly reason: string
72
+ readonly confidence: number
73
+ }
74
+
75
+ /** Inputs to {@link decideTier}. */
76
+ export interface DecideInput {
77
+ readonly config: ResolvedConfig
78
+ readonly state: RouteState
79
+ readonly intent: IntentResult
80
+ readonly rule: RuleHit | null
81
+ /** Session/plugin override; `undefined` means the configured routing mode. */
82
+ readonly override: RoutingMode | undefined
83
+ readonly now: number
84
+ }
85
+
86
+ /** Whether the current failure escalation is still in force. */
87
+ export function escalationActive(state: RouteState, now: number): boolean {
88
+ return state.escalation !== undefined && state.escalation.until > now
89
+ }
90
+
91
+ /**
92
+ * Apply the double-threshold hysteresis to a classifier-driven change. The
93
+ * anchor is only honoured when the applied tier itself came from the
94
+ * classifier: an escalation, plan-mode, rule or manual decision is a deliberate
95
+ * instruction, so returning from it must not be damped (otherwise a session
96
+ * that escalated once never returns to the cheap tier).
97
+ */
98
+ function withHysteresis(state: RouteState, proposed: TierId, confidence: number, config: ResolvedConfig): TierId {
99
+ const applied = state.appliedTier
100
+ if (applied === undefined || applied === proposed) return proposed
101
+ const anchorSource = state.appliedSource
102
+ if (anchorSource !== 'judge' && anchorSource !== 'posterior' && anchorSource !== 'default') return proposed
103
+ const { toStrong, toCheap } = config.intent.hysteresis
104
+ if (proposed === 'strong' && confidence < toStrong) return applied
105
+ if (proposed === 'cheap' && confidence >= toCheap) return applied
106
+ return proposed
107
+ }
108
+
109
+ /**
110
+ * Resolve the tier for the current step.
111
+ *
112
+ * Precedence (highest first): explicit override, active failure escalation,
113
+ * plan mode, declarative rule, fingerprint posterior, classifier verdict.
114
+ * Hysteresis applies only to the classifier verdict, so an explicit override
115
+ * or an escalation takes effect immediately.
116
+ *
117
+ * @param input - the live state and the classification.
118
+ * @returns the decision with its provenance.
119
+ */
120
+ export function decideTier(input: DecideInput): Decision {
121
+ const { state, config, intent, rule, override, now } = input
122
+ if (override === 'strong' || override === 'cheap') {
123
+ return { tier: override, source: 'manual', reason: `/tier ${override}`, confidence: 1 }
124
+ }
125
+ if (escalationActive(state, now)) {
126
+ return {
127
+ tier: 'strong',
128
+ source: 'escalation',
129
+ reason: `escalated after ${String(state.escalation?.count ?? 0)} failure(s)`,
130
+ confidence: 1,
131
+ }
132
+ }
133
+ if (state.planActive) {
134
+ return { tier: 'strong', source: 'plan-mode', reason: 'plan mode is active', confidence: 1 }
135
+ }
136
+ if (fallbackActive(state.fallback, now)) {
137
+ return {
138
+ tier: intent.tier,
139
+ source: 'fallback',
140
+ reason: `fallback chain entry ${String((state.fallback?.index ?? 0) + 1)}`,
141
+ confidence: intent.confidence,
142
+ }
143
+ }
144
+ if (rule !== null) {
145
+ return { tier: rule.tier, source: 'rule', reason: `rule ${rule.id}`, confidence: 1 }
146
+ }
147
+ // The posterior opinion (including its exploration roll) is taken once per
148
+ // user input by the router and stored on the state, so a tool-loop step
149
+ // cannot re-roll it into a different tier.
150
+ const posterior = state.probe ?? null
151
+ if (posterior !== null) {
152
+ return {
153
+ tier: posterior,
154
+ source: 'posterior',
155
+ reason: `fingerprint ${intent.fingerprint} posterior`,
156
+ confidence: 1,
157
+ }
158
+ }
159
+ const tier = withHysteresis(state, intent.tier, intent.confidence, config)
160
+ const source: RouteSource = intent.shortCircuit !== undefined ? 'rule' : 'judge'
161
+ const reason = tier === intent.tier
162
+ ? intent.reasons.join('; ')
163
+ : `hysteresis kept ${tier} (proposed ${intent.tier} at confidence ${intent.confidence.toFixed(2)})`
164
+ return { tier, source, reason, confidence: intent.confidence }
165
+ }
166
+
167
+ /** Whether the low-confidence judge should be consulted for this input. */
168
+ export function judgeNeeded(
169
+ config: ResolvedConfig,
170
+ state: RouteState,
171
+ intent: IntentResult,
172
+ rule: RuleHit | null,
173
+ now: number,
174
+ ): boolean {
175
+ if (!config.intent.judge.enabled) return false
176
+ if (rule !== null) return false
177
+ if (intent.shortCircuit !== undefined) return false
178
+ if (intent.confidence >= config.intent.ruleThreshold) return false
179
+ if (state.judge.failures >= config.intent.judge.unavailableSkip) return false
180
+ return now - state.judge.lastCall >= config.intent.judge.cooldownMs
181
+ }
182
+
183
+ /** Whether the middle band should start cheap and verify on a signal. */
184
+ export function attemptBandApplies(config: ResolvedConfig, intent: IntentResult): boolean {
185
+ if (!config.intent.attemptBand.enabled) return false
186
+ if (intent.shortCircuit !== undefined) return false
187
+ return intent.confidence >= config.intent.attemptBand.tauLow && intent.confidence < config.intent.ruleThreshold
188
+ }
189
+
190
+ /** Record one judge call attempt. */
191
+ export function noteJudgeCall(state: RouteState, now: number, ok: boolean): void {
192
+ state.judge.lastCall = now
193
+ state.judge.failures = ok ? 0 : state.judge.failures + 1
194
+ }
195
+
196
+ /**
197
+ * Record one failure against the escalation counter.
198
+ * @param state - the agent's state.
199
+ * @param signature - the failure signature (`code|fingerprint`); only identical
200
+ * signatures accumulate when `escalation.signature` is enabled.
201
+ * @param config - the resolved configuration.
202
+ * @param now - current epoch millis.
203
+ * @returns whether this failure escalated the tier.
204
+ */
205
+ export function noteFailure(state: RouteState, signature: string, config: ResolvedConfig, now: number): boolean {
206
+ const current = state.escalation
207
+ const sameSignature = config.escalation.signature ? current?.signature === signature : true
208
+ const withinWindow = current !== undefined && now - current.lastAt <= config.escalation.windowMs
209
+ const count = sameSignature && current !== undefined && withinWindow ? current.count + 1 : 1
210
+ const escalated = count >= config.escalation.threshold
211
+ state.escalation = {
212
+ count,
213
+ signature: config.escalation.signature ? signature : '',
214
+ until: escalated ? now + config.escalation.ttlMs : (current?.until ?? 0),
215
+ rung: escalated ? (current?.rung ?? 0) + 1 : (current?.rung ?? 0),
216
+ lastAt: now,
217
+ }
218
+ return escalated
219
+ }
220
+
221
+ /** Clear an expired escalation lazily. A record that never escalated keeps its window count. */
222
+ export function clearExpiredEscalation(state: RouteState, now: number): void {
223
+ if (state.escalation !== undefined && state.escalation.until > 0 && state.escalation.until <= now) {
224
+ state.escalation = undefined
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Advance the agent's fallback chain for one tier after an unusable route.
230
+ * @returns whether a chain entry was taken (false = chain exhausted).
231
+ */
232
+ export function noteFallback(
233
+ state: RouteState,
234
+ tier: TierId,
235
+ chainLength: number,
236
+ config: ResolvedConfig,
237
+ now: number,
238
+ ): boolean {
239
+ const next = advanceFallback(state.fallback, tier, chainLength, now, config.escalation.fallbackTtlMs)
240
+ if (next === null) {
241
+ if (state.fallback?.tier === tier) state.fallback = undefined
242
+ return false
243
+ }
244
+ state.fallback = next
245
+ return true
246
+ }