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/routing.ts ADDED
@@ -0,0 +1,575 @@
1
+ /**
2
+ * The routing wiring: the listeners that turn a classification into a tier, and
3
+ * the failure/fallback handlers that keep a cheap run alive.
4
+ *
5
+ * Every registration is an effect on the plugin fiber. The request listener is
6
+ * registered at load time on the root scope with `{ prepend: true }` so it wraps
7
+ * the official `installModelSelection` listener (registered later, during agent
8
+ * setup) and its replacement wins. It always awaits `next()` exactly once and
9
+ * never returns `undefined`: the inner listener destructures the result without
10
+ * a guard.
11
+ *
12
+ * @module dsh-autotier/routing
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
+ import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
18
+ // Type-only import for the `plan/mode` SessionEventMap augmentation.
19
+ import type {} from '@deepseek-ai/dsh-plan-mode'
20
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
21
+ import type { ResolvedConfig } from './config.ts'
22
+ import { classifyIntent, evaluateRules, type IntentInput, type IntentResult } from './intent.ts'
23
+ import { runJudge } from './judge.ts'
24
+ import {
25
+ attemptBandApplies,
26
+ clearExpiredEscalation,
27
+ decideTier,
28
+ escalationActive,
29
+ judgeNeeded,
30
+ noteFailure,
31
+ noteFallback,
32
+ noteJudgeCall,
33
+ type Decision,
34
+ type RouteState,
35
+ } from './policy.ts'
36
+ import type { AutotierService } from './service.ts'
37
+ import type { AgentStateStore } from './state.ts'
38
+ import { classifyFallback, EFFORT_LADDER, effortRank, escalationLadder, resolveRoute } from './tiers.ts'
39
+ import type { RouteSource, TierId, TierRoute } from './types.ts'
40
+
41
+ /** One proposed tier, offered to third parties on the `autotier/route` event. */
42
+ export interface RouteProposal {
43
+ readonly agent: Agent
44
+ readonly turn: number
45
+ readonly step: number
46
+ readonly tier: TierId
47
+ readonly source: RouteSource
48
+ readonly reason: string
49
+ readonly confidence: number
50
+ }
51
+
52
+ /** A third party's replacement tier. Returning one from a listener vetoes. */
53
+ export interface RouteVeto {
54
+ readonly tier: TierId
55
+ readonly reason: string
56
+ }
57
+
58
+ /** Emitted whenever the effective tier changes. */
59
+ export interface TierChange {
60
+ readonly agent: Agent
61
+ readonly from: TierId | undefined
62
+ readonly to: TierId
63
+ readonly source: RouteSource
64
+ readonly reason: string
65
+ readonly route: TierRoute
66
+ }
67
+
68
+ declare module '@deepseek-ai/cordis' {
69
+ interface Events {
70
+ /**
71
+ * Serial veto over one proposed tier. Listeners run in order and the first
72
+ * one returning a {@link RouteVeto} replaces the proposal.
73
+ * @mode serial
74
+ */
75
+ 'autotier/route'(proposal: RouteProposal): RouteVeto | void | Promise<RouteVeto | void>
76
+ /**
77
+ * The effective tier changed for one agent.
78
+ * @mode emit
79
+ */
80
+ 'autotier/tier-changed'(payload: TierChange): void
81
+ }
82
+ }
83
+
84
+ /** Options for {@link AutotierRouter}. */
85
+ export interface RouterOptions {
86
+ readonly ctx: Context
87
+ readonly service: AutotierService
88
+ readonly states: AgentStateStore
89
+ }
90
+
91
+ /** How long third parties have to veto a proposed tier before the turn proceeds. */
92
+ const VETO_TIMEOUT_MS = 250
93
+
94
+ /** Extract the plain text of one message's content blocks. */
95
+ function textOf(content: readonly { type: string; text?: string }[]): string {
96
+ return content
97
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
98
+ .map(block => block.text)
99
+ .join('\n')
100
+ }
101
+
102
+ /** The error code of a thrown value, when it carries one. */
103
+ function codeOf(error: unknown): string {
104
+ if (error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code
105
+ return 'UNKNOWN'
106
+ }
107
+
108
+ /** Whether a classified intent is complex enough to open plan mode. */
109
+ function shouldPlan(intent: IntentResult): boolean {
110
+ if (intent.tier !== 'strong') return false
111
+ if (intent.scenario === 'review') return false
112
+ return intent.scenario === 'planning' || intent.signals.score >= 3
113
+ }
114
+
115
+ /** The router owns every autotier listener. */
116
+ export class AutotierRouter {
117
+ private readonly ctx: Context
118
+ private readonly service: AutotierService
119
+ private readonly states: AgentStateStore
120
+ private readonly pendingJudges = new Set<Promise<void>>()
121
+ /** Per-session classifier counters, keyed by session so no agent registry is needed. */
122
+ private readonly counters = new WeakMap<Session, { toolNames: string[]; messageCount: number }>()
123
+ /** Router-owned lifetime signal: aborts in-flight judge calls on unload. */
124
+ private readonly lifetime = new AbortController()
125
+ private disposed = false
126
+
127
+ /**
128
+ * Register every listener on the plugin fiber.
129
+ * @param options - the plugin context, the service and the state store.
130
+ */
131
+ constructor(options: RouterOptions) {
132
+ this.ctx = options.ctx
133
+ this.service = options.service
134
+ this.states = options.states
135
+ this.ctx.on('agent/inbox/inserted', payload => this.onInboxInserted(payload.agent, payload.message), { prepend: true })
136
+ this.ctx.on('agent/request', (payload, next) => this.onRequest(payload.agent, payload.turn, payload.step, next), { prepend: true })
137
+ this.ctx.on('agent/error', payload => this.onAgentError(payload.agent, payload.error))
138
+ this.ctx.on('agent/request-error', (payload, next) => this.onRequestError(payload.agent, payload.provider, payload.failure, next))
139
+ this.ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
140
+ this.ctx.effect(() => () => {
141
+ this.disposed = true
142
+ this.lifetime.abort()
143
+ })
144
+ }
145
+
146
+ /** Pending judge calls (diagnostics and tests). */
147
+ get judgeCallsInFlight(): number {
148
+ return this.pendingJudges.size
149
+ }
150
+
151
+ /** The classifier input for one agent. */
152
+ private inputFor(agent: Agent, text: string, hasImage: boolean): IntentInput {
153
+ const counters = this.counterFor(agent.session)
154
+ return {
155
+ text,
156
+ toolNames: counters.toolNames,
157
+ hasImage,
158
+ messageCount: counters.messageCount,
159
+ cwd: agent.session.header.cwd ?? '',
160
+ }
161
+ }
162
+
163
+ /** The per-session classifier counters, created on first use. */
164
+ private counterFor(session: Session): { toolNames: string[]; messageCount: number } {
165
+ let counters = this.counters.get(session)
166
+ if (counters === undefined) {
167
+ counters = { toolNames: [], messageCount: 0 }
168
+ this.counters.set(session, counters)
169
+ }
170
+ return counters
171
+ }
172
+
173
+ /** The routing mode in force for one agent. */
174
+ private modeFor(agent: Agent): ResolvedConfig['routingMode'] {
175
+ return this.states.for(agent).override ?? this.service.config().routingMode
176
+ }
177
+
178
+ /** Capture the newest user input, classify it, and start the judge when needed. */
179
+ private onInboxInserted(agent: Agent, message: { source?: { kind?: string }; content?: readonly { type: string; text?: string }[] }): void {
180
+ if (message.source?.kind !== 'user') return
181
+ const text = textOf(message.content ?? [])
182
+ const hasImage = (message.content ?? []).some(block => block.type === 'image')
183
+ const state = this.states.for(agent)
184
+ const config = this.service.config()
185
+ const input = this.inputFor(agent, text, hasImage)
186
+ state.input = input
187
+ state.decision = classifyIntent(input, { rules: this.service.rules(), scenarios: config.intent.scenarios })
188
+ state.verified = false
189
+ // `reviewOwedFor` deliberately survives a new input: a cheap attempt that
190
+ // hit a signal still owes one strong pass for that shape of request, and a
191
+ // turn-ending failure has no later step in its own turn to spend it on.
192
+ // The posterior opinion (with its exploration roll) is taken once per user
193
+ // input; every step of the turn reuses it.
194
+ const probe = this.service.posteriors().verdict(state.decision.fingerprint)
195
+ state.probe = probe ?? undefined
196
+ if (this.modeFor(agent) !== 'auto') return
197
+ const rule = evaluateRules(this.service.rules(), input)
198
+ if (shouldPlan(state.decision) && !state.planActive) this.enterPlanMode(agent)
199
+ if (judgeNeeded(config, state, state.decision, rule, Date.now())) {
200
+ this.startJudge(agent, config, text, state.decision)
201
+ }
202
+ }
203
+
204
+ /** Fire the judge without blocking the emit dispatch. */
205
+ private startJudge(agent: Agent, config: ResolvedConfig, text: string, local: IntentResult): void {
206
+ const state = this.states.for(agent)
207
+ // Arm the cooldown before the call: a second input arriving while this one
208
+ // is still in flight must not start a second judge.
209
+ state.judge.lastCall = Date.now()
210
+ const signal = this.lifetime.signal
211
+ // The decision object is the generation token: a newer input replaces
212
+ // `state.decision`, and a stale judge answer must not overwrite it.
213
+ const generation = local
214
+ const task = runJudge(this.ctx, config, text, signal).then((outcome) => {
215
+ if (this.disposed) return
216
+ noteJudgeCall(state, Date.now(), outcome.ok)
217
+ if (state.decision !== generation) return
218
+ if (!outcome.ok || outcome.tier === undefined || outcome.scenario === undefined) {
219
+ this.ctx.logger.debug('dsh-autotier: judge abstained (%s); keeping the local verdict', outcome.detail)
220
+ return
221
+ }
222
+ // The judge refines the classifier's verdict; it never overrides an
223
+ // explicit short-circuit or a rule hit.
224
+ state.decision = {
225
+ ...local,
226
+ scenario: outcome.scenario,
227
+ tier: outcome.tier,
228
+ confidence: 0.75,
229
+ reasons: [...local.reasons, outcome.detail],
230
+ }
231
+ }).catch((error: unknown) => {
232
+ if (this.disposed) return
233
+ noteJudgeCall(state, Date.now(), false)
234
+ this.ctx.logger.warn('dsh-autotier: judge call failed: %o', error)
235
+ }).finally(() => {
236
+ this.pendingJudges.delete(task)
237
+ })
238
+ this.pendingJudges.add(task)
239
+ }
240
+
241
+ /** Open plan mode through the service, or through the log when it is absent. */
242
+ private enterPlanMode(agent: Agent): void {
243
+ const planMode = this.ctx.get('planMode') as { set(agent: Agent, active: boolean): string } | undefined
244
+ const state = this.states.for(agent)
245
+ if (planMode !== undefined) {
246
+ try {
247
+ const outcome = planMode.set(agent, true)
248
+ if (outcome !== 'noop') {
249
+ state.planActive = true
250
+ this.ctx.logger.info('dsh-autotier: plan mode %s for a complex instruction', outcome)
251
+ }
252
+ return
253
+ } catch (error) {
254
+ this.ctx.logger.warn('dsh-autotier: planMode.set failed (%o); falling back to the session log', error)
255
+ }
256
+ }
257
+ try {
258
+ agent.session.append('plan/mode', { active: true })
259
+ state.planActive = true
260
+ } catch (error) {
261
+ this.ctx.logger.warn('dsh-autotier: could not open plan mode (%o)', error)
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Offer the proposal to third parties on the `autotier/route` serial event.
267
+ * A listener failure is contained, and a listener that never settles cannot
268
+ * stall the turn: the race resolves with our own decision after the timeout.
269
+ * The timer is owned by `ctx.effect`, so unloading clears it (no HMR leak).
270
+ */
271
+ private async serialVeto(proposal: RouteProposal): Promise<RouteVeto | void> {
272
+ const deadline = new Promise<undefined>((resolve) => {
273
+ this.ctx.effect(() => {
274
+ const timer = setTimeout(() => { resolve(undefined) }, VETO_TIMEOUT_MS)
275
+ return () => { clearTimeout(timer) }
276
+ })
277
+ })
278
+ const offered = this.ctx.serial('autotier/route', proposal).catch((error: unknown) => {
279
+ this.ctx.logger.warn('dsh-autotier: autotier/route listener failed: %o', error)
280
+ return undefined
281
+ })
282
+ return Promise.race([offered, deadline])
283
+ }
284
+
285
+ /** The tier landing for one tier, resolving the vision override, an active
286
+ * fallback record, and the effort-first escalation ladder.
287
+ *
288
+ * The ladder is the point of escalation: raise the current model's effort one
289
+ * step at a time (the KV prefix survives and the official notice stays quiet
290
+ * for an effort-only change) before paying for a model switch. `rung` counts
291
+ * how many times escalation has triggered for this agent, so repeated failures
292
+ * walk the ladder instead of jumping to the strongest landing.
293
+ */
294
+ private routeFor(
295
+ tier: TierId,
296
+ config: ResolvedConfig,
297
+ intent: IntentResult | undefined,
298
+ state: RouteState,
299
+ now: number,
300
+ base?: LlmCallConfig,
301
+ ): TierRoute {
302
+ if (intent?.signals !== undefined && intent.shortCircuit === 'image') {
303
+ const vision = config.tiers.vision
304
+ return { provider: vision.provider, model: vision.model }
305
+ }
306
+ const entry = tier === 'strong' ? config.tiers.strong : config.tiers.cheap
307
+ // An active fallback record pins the agent to one chain entry; the tier's
308
+ // effort still applies, so a fallback model keeps the intended reasoning
309
+ // budget.
310
+ if (state.fallback !== undefined && state.fallback.tier === tier && state.fallback.until > now) {
311
+ const chainEntry = entry.fallback[state.fallback.index]
312
+ if (chainEntry !== undefined) {
313
+ const floor = entry.followSession && base?.reasoningEffort !== undefined ? undefined : entry.effort
314
+ return floor === undefined
315
+ ? { provider: chainEntry.provider, model: chainEntry.model }
316
+ : { provider: chainEntry.provider, model: chainEntry.model, effort: floor }
317
+ }
318
+ }
319
+ if (tier === 'strong' && state.escalation !== undefined && state.escalation.until > now) {
320
+ // The ladder is a property of the tier configuration, so it is always
321
+ // computed from the cheap tier's own landing — computing it from the
322
+ // current request config would drift upward as each rung lands and make
323
+ // the rung counter skip entries.
324
+ const cheapLanding = this.tierRoute('cheap', config)
325
+ const ladder = escalationLadder(cheapLanding, cheapLanding, this.tierRoute('strong', config))
326
+ let index = Math.min(Math.max(state.escalation.rung - 1, 0), Math.max(ladder.length - 1, 0))
327
+ // Never lower the effort the session already carries: skip rungs that
328
+ // would step below the current request's effort.
329
+ const currentRank = effortRank(base?.reasoningEffort ?? cheapLanding.effort ?? 'low')
330
+ while (index < ladder.length - 1) {
331
+ const candidate = ladder[index]
332
+ const candidateRank = candidate?.route.effort === undefined ? EFFORT_LADDER.length : effortRank(candidate.route.effort)
333
+ if (candidateRank >= currentRank) break
334
+ index += 1
335
+ }
336
+ const rung = ladder[index]
337
+ if (rung !== undefined) return rung.route
338
+ }
339
+ return this.tierRoute(tier, config, base)
340
+ }
341
+
342
+ /**
343
+ * The configured landing of one tier. `followSession` means the session's own
344
+ * effort wins when it has one; when it has none, the tier's configured effort
345
+ * is the floor (an omitted effort would fall through to the adapter default,
346
+ * which is the strongest level).
347
+ */
348
+ private tierRoute(tier: TierId, config: ResolvedConfig, base?: LlmCallConfig): TierRoute {
349
+ const entry = tier === 'strong' ? config.tiers.strong : config.tiers.cheap
350
+ if (!entry.followSession) {
351
+ return { provider: entry.provider, model: entry.model, effort: entry.effort }
352
+ }
353
+ if (base?.reasoningEffort === undefined && entry.effort !== undefined) {
354
+ return { provider: entry.provider, model: entry.model, effort: entry.effort }
355
+ }
356
+ return { provider: entry.provider, model: entry.model }
357
+ }
358
+
359
+ /** Resolve the tier for this step and apply it to the proposed configuration. */
360
+ private async onRequest(
361
+ agent: Agent,
362
+ turn: number,
363
+ step: number,
364
+ next: () => Promise<LlmCallConfig>,
365
+ ): Promise<LlmCallConfig> {
366
+ const base = await next()
367
+ try {
368
+ return await this.routeRequest(agent, turn, step, base)
369
+ } catch (error) {
370
+ // The request seam must never break a turn: a routing defect degrades to
371
+ // the session's own configuration and is reported loudly.
372
+ this.ctx.logger.error('dsh-autotier: routing failed, using the session configuration: %o', error)
373
+ return base
374
+ }
375
+ }
376
+
377
+ /** The routing body, separated so one try/catch guards the whole seam. */
378
+ private async routeRequest(agent: Agent, turn: number, step: number, base: LlmCallConfig): Promise<LlmCallConfig> {
379
+ const mode = this.modeFor(agent)
380
+ if (mode === 'off' || mode === 'delegated') return base
381
+ const state = this.states.for(agent)
382
+ const config = this.service.config()
383
+ const now = Date.now()
384
+ clearExpiredEscalation(state, now)
385
+ const intent = state.decision
386
+ if (intent === undefined) return base
387
+ const rule = state.input === undefined ? null : evaluateRules(this.service.rules(), state.input)
388
+ let decision: Decision = decideTier({
389
+ config,
390
+ state,
391
+ intent,
392
+ rule,
393
+ override: state.override,
394
+ now,
395
+ })
396
+ // Attempt-first band: only a classifier-driven verdict may be downgraded —
397
+ // an explicit rule, plan mode, escalation or manual override wins outright.
398
+ const classifierDriven = decision.source === 'judge' || decision.source === 'default'
399
+ if (classifierDriven && attemptBandApplies(config, intent) && !state.verified && !escalationActive(state, now)) {
400
+ decision = {
401
+ tier: 'cheap',
402
+ source: 'default',
403
+ reason: `${intent.reasons.join('; ')}; attempt-first band`,
404
+ confidence: intent.confidence,
405
+ }
406
+ }
407
+ // Attempt-first review: a cheap-run signal owes one strong pass for the
408
+ // SAME SHAPE of request (fingerprint), so an unrelated later task neither
409
+ // inherits nor spends it.
410
+ if (state.reviewOwedFor !== undefined && state.reviewOwedFor === intent.fingerprint && !state.verified) {
411
+ state.verified = true
412
+ state.reviewOwedFor = undefined
413
+ decision = {
414
+ tier: 'strong',
415
+ source: 'escalation',
416
+ reason: 'attempt-first strong review',
417
+ confidence: 1,
418
+ }
419
+ }
420
+ const proposal: RouteProposal = {
421
+ agent,
422
+ turn,
423
+ step,
424
+ tier: decision.tier,
425
+ source: decision.source,
426
+ reason: decision.reason,
427
+ confidence: decision.confidence,
428
+ }
429
+ const veto = await this.serialVeto(proposal)
430
+ const tier = veto?.tier ?? decision.tier
431
+ const source: RouteSource = veto === undefined || veto === null ? decision.source : 'manual'
432
+ const reason = veto === undefined || veto === null ? decision.reason : `veto: ${veto.reason}`
433
+ const route = this.routeFor(tier, config, intent, state, now, base)
434
+ const applied = resolveRoute(base, route)
435
+ if (state.appliedTier !== tier) {
436
+ const from = state.appliedTier
437
+ state.appliedTier = tier
438
+ state.appliedSource = source
439
+ this.ctx.emit('autotier/tier-changed', { agent, from, to: tier, source, reason, route })
440
+ } else {
441
+ state.appliedSource = source
442
+ }
443
+ this.ctx.logger.debug(
444
+ 'dsh-autotier: turn=%d step=%d tier=%s source=%s (%s)',
445
+ turn,
446
+ step,
447
+ tier,
448
+ source,
449
+ reason,
450
+ )
451
+ return applied
452
+ }
453
+
454
+ /** Count failures and escalate on the configured signature recurrence. */
455
+ private onAgentError(agent: Agent, error: unknown): void {
456
+ const mode = this.modeFor(agent)
457
+ // Failures in a session that opted out of routing are not ours to count.
458
+ if (mode === 'off' || mode === 'delegated') return
459
+ const state = this.states.for(agent)
460
+ const config = this.service.config()
461
+ const signature = `${codeOf(error)}|${state.decision?.fingerprint ?? ''}`
462
+ const now = Date.now()
463
+ // An attempt-first turn that hits a signal owes one strong review for the
464
+ // SAME decision (a later input is a different task and must re-earn it).
465
+ if (state.decision !== undefined && attemptBandApplies(config, state.decision) && !state.verified) {
466
+ state.reviewOwedFor = state.decision.fingerprint
467
+ }
468
+ const alreadyEscalated = escalationActive(state, now)
469
+ // Escalation is a cheap-tier remedy: a strong-tier failure is not evidence
470
+ // that the cheap tier was wrong.
471
+ const failedTier = state.appliedTier ?? 'cheap'
472
+ if (failedTier !== 'cheap' && !alreadyEscalated) return
473
+ const escalatedNow = noteFailure(state, signature, config, now)
474
+ if (escalatedNow && !alreadyEscalated) {
475
+ const tier = this.routeFor('strong', config, state.decision, state, now)
476
+ this.ctx.logger.warn(
477
+ 'dsh-autotier: escalating after %d recurring failure(s) (%s) -> %s/%s',
478
+ state.escalation?.count ?? 0,
479
+ signature,
480
+ tier.provider,
481
+ tier.model,
482
+ )
483
+ this.ctx.emit('autotier/tier-changed', {
484
+ agent,
485
+ from: state.appliedTier,
486
+ to: 'strong',
487
+ source: 'escalation',
488
+ reason: signature,
489
+ route: tier,
490
+ })
491
+ }
492
+ if (state.decision !== undefined) {
493
+ this.service.posteriors().record(state.decision.fingerprint, state.appliedTier ?? 'cheap', false, now)
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Walk the tier's fallback chain. Permanent codes switch immediately;
499
+ * transient codes wait for `dsh-llm-retry` to exhaust its own retries first
500
+ * (this listener is registered after it on purpose).
501
+ */
502
+ private async onRequestError(
503
+ agent: Agent,
504
+ provider: string,
505
+ failure: { code?: unknown; status?: unknown },
506
+ next: () => Promise<{ kind: 'retry' } | undefined>,
507
+ ): Promise<{ kind: 'retry' } | undefined> {
508
+ const mode = this.modeFor(agent)
509
+ // A session that opted out of routing keeps its own failure handling: the
510
+ // chain is autotier's, so it must not re-dispatch a route we do not own.
511
+ if (mode === 'off' || mode === 'delegated') return next()
512
+ const config = this.service.config()
513
+ const verdict = classifyFallback(failure)
514
+ if (verdict === 'ignore' || verdict === 'unknown') return next()
515
+ const state = this.states.for(agent)
516
+ const tier: TierId = escalationActive(state, Date.now()) ? 'strong' : (state.appliedTier ?? 'cheap')
517
+ const chain = tier === 'strong' ? config.tiers.strong.fallback : config.tiers.cheap.fallback
518
+ if (chain.length === 0) return next()
519
+ if (verdict === 'transient') {
520
+ const downstream = await next()
521
+ if (downstream !== undefined) return downstream
522
+ }
523
+ const now = Date.now()
524
+ if (noteFallback(state, tier, chain.length, config, now)) {
525
+ this.ctx.logger.warn(
526
+ 'dsh-autotier: provider "%s" failed with %s; switching to fallback entry %d of the %s tier',
527
+ provider,
528
+ String(typeof failure.code === 'string' ? failure.code : `status ${String(failure.status ?? '?')}`),
529
+ (state.fallback?.index ?? 0) + 1,
530
+ tier,
531
+ )
532
+ return { kind: 'retry' }
533
+ }
534
+ return next()
535
+ }
536
+
537
+ /** Maintain the classifier counters and the plan-mode fallback fold. */
538
+ private onSessionEvent(session: Session, event: SessionEvent): void {
539
+ const counters = this.counterFor(session)
540
+ if (event.type === 'user/message') {
541
+ counters.messageCount += 1
542
+ return
543
+ }
544
+ if (event.type === 'tool/call') {
545
+ const name = (event.data as { name?: unknown }).name
546
+ if (typeof name === 'string' && !counters.toolNames.includes(name)) counters.toolNames.push(name)
547
+ return
548
+ }
549
+ if (event.type === 'plan/mode') {
550
+ const agent = this.agentOf(session)
551
+ if (agent !== undefined) this.states.for(agent).planActive = (event.data as { active?: unknown }).active === true
552
+ return
553
+ }
554
+ if (event.type === 'turn/end') {
555
+ const reason = (event.data as { reason?: { kind?: unknown } }).reason?.kind
556
+ const agent = this.agentOf(session)
557
+ if (agent === undefined) return
558
+ const state = this.states.for(agent)
559
+ if (reason === 'completed' && state.decision !== undefined) {
560
+ this.service.posteriors().record(state.decision.fingerprint, state.appliedTier ?? 'cheap', true, Date.now())
561
+ }
562
+ }
563
+ }
564
+
565
+ /** Resolve the agent that owns one session, when the registry is reachable. */
566
+ private agentOf(session: Session): Agent | undefined {
567
+ const agents = this.ctx.get('agents')
568
+ if (agents === undefined) return undefined
569
+ try {
570
+ return agents.get(session.id)
571
+ } catch {
572
+ return undefined
573
+ }
574
+ }
575
+ }