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/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
|
+
}
|