@weniatall/dsh-task-models 0.0.0-stage → 0.2.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/lib/index.js ADDED
@@ -0,0 +1,780 @@
1
+ /**
2
+ * dsh-task-models: per-task-type subagent model routing for DeepSeek Harness.
3
+ *
4
+ * The plugin is standalone: it does not read or require any Agent Preset or
5
+ * role-routing plugin. It routes only delegated child sessions, decides once per
6
+ * child at its first request, and optionally asks TypeSafe Jev (or a
7
+ * configured lightweight classifier) when deterministic keywords do not
8
+ * settle the task type.
9
+ *
10
+ * @module dsh-task-models
11
+ */
12
+ import z from '@deepseek-ai/schemastery'
13
+ import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
14
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
15
+ import { homedir } from 'node:os'
16
+ import { dirname, join } from 'node:path'
17
+ import zlib from 'node:zlib'
18
+ import {
19
+ DEFAULT_JEV,
20
+ DEFAULT_ROUTING,
21
+ childRouteIsExplicit,
22
+ isSubagent,
23
+ parseJevChoice,
24
+ parseProfile,
25
+ planModeActive,
26
+ profileJevRequest,
27
+ profileSystem,
28
+ resolveConfig,
29
+ selectProfile,
30
+ taskTextOf,
31
+ } from './core.js'
32
+
33
+ export const name = '@weniatall/dsh-task-models'
34
+ export const inject = ['settings', 'llm']
35
+ export const NS = 'task-models'
36
+ export const SETTINGS_RPC_CHANNEL = '/task-models-settings'
37
+ export const JEV_PING_INSTRUCTIONS = 'Is this a valid test ping?'
38
+ export function keyFilePath() {
39
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'task-models', 'typesafe.key')
40
+ }
41
+
42
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
43
+
44
+ function liveField(schema) {
45
+ return typeof schema?.volatile === 'function' ? schema.volatile() : schema
46
+ }
47
+
48
+ function isVolatileRef(value) {
49
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
50
+ if (typeof value.get !== 'function') return false
51
+ return VOLATILE_WRITE in value || Object.keys(value).length === 0
52
+ }
53
+
54
+ /** Plain composition layer for the legacy settings seam. */
55
+ function settingsBase(ctx, config) {
56
+ return unwrapConfig(ctx.fiber?.entry?.options?.config ?? config ?? {})
57
+ }
58
+
59
+ function unwrapConfig(value, seen = new Set()) {
60
+ if (isVolatileRef(value)) return unwrapConfig(value.get(), seen)
61
+ if (value === null || typeof value !== 'object') return value
62
+ seen.add(value)
63
+ if (Array.isArray(value)) return value.map((item) => unwrapConfig(item, seen))
64
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapConfig(item, seen)]))
65
+ }
66
+
67
+ function settingsNamespace(ctx, fallback) {
68
+ if (typeof ctx.settings?.register === 'function') return fallback
69
+ const id = ctx.fiber?.entry?.options?.id
70
+ return typeof id === 'string' && id.length > 0 ? id : fallback
71
+ }
72
+
73
+ function openSettingsScope(ctx, ns, schema, config) {
74
+ if (typeof ctx.settings?.register === 'function') {
75
+ return ctx.settings.register(ns, schema, { base: settingsBase(ctx, config) })
76
+ }
77
+ const read = () => {
78
+ const raw = typeof ctx.settings?.get === 'function' ? ctx.settings.get(ns) : config
79
+ return unwrapConfig(schema(unwrapConfig(raw ?? {})))
80
+ }
81
+ return {
82
+ get: read,
83
+ watch(listener) {
84
+ if (typeof ctx.on !== 'function') return () => {}
85
+ return ctx.on('loader/volatile-update', () => listener(read()))
86
+ },
87
+ }
88
+ }
89
+
90
+ const routeEntry = z.object({
91
+ provider: z.string().default(''),
92
+ model: z.string().default(''),
93
+ reasoningEffort: z.string().default(''),
94
+ })
95
+
96
+ const profileEntry = z.object({
97
+ id: z.string().required(),
98
+ name: z.string().default(''),
99
+ detail: z.string().default(''),
100
+ keywords: z.array(z.string()).default([]),
101
+ provider: z.string().required(),
102
+ model: z.string().required(),
103
+ reasoningEffort: z.string().default(''),
104
+ enabled: z.boolean().default(true),
105
+ })
106
+
107
+ /** Settings section and Cordis entry configuration. */
108
+ export const Config = z.object({
109
+ routing: liveField(z.object({
110
+ enabled: z.boolean().default(false),
111
+ fallback: z.string().default('taskRole'),
112
+ useJev: z.boolean().default(true),
113
+ respectExplicitChildModel: z.boolean().default(true),
114
+ classifier: routeEntry.default({}),
115
+ }).default({})),
116
+ profiles: liveField(z.array(profileEntry).default([])),
117
+ jev: liveField(z.object({
118
+ enabled: z.boolean().default(false),
119
+ baseURL: z.string().default(DEFAULT_JEV.baseURL),
120
+ model: z.string().default(DEFAULT_JEV.model),
121
+ minConfidence: z.number().min(0).max(1).default(DEFAULT_JEV.minConfidence),
122
+ timeoutMs: z.number().step(1).min(100).max(60_000).default(DEFAULT_JEV.timeoutMs),
123
+ maxStateChars: z.number().step(1).min(500).max(100_000).default(DEFAULT_JEV.maxStateChars),
124
+ }).default({})),
125
+ })
126
+
127
+ export { isVolatileRef, liveField, settingsBase, unwrapConfig }
128
+
129
+ function badSettingsRequest(message) {
130
+ return {
131
+ ok: false,
132
+ error: {
133
+ code: 'bad-request',
134
+ message,
135
+ details: { issues: [{ code: 'custom', path: [], message }] },
136
+ },
137
+ }
138
+ }
139
+
140
+ function settingsRpcView(settingsProvider, fallbackValue, targetNs = NS) {
141
+ const descriptor = typeof settingsProvider?.describe === 'function'
142
+ ? settingsProvider.describe({ redactSecrets: true }).find((entry) => entry.ns === targetNs)
143
+ : undefined
144
+ if (descriptor !== undefined) {
145
+ return {
146
+ writable: settingsProvider.writable,
147
+ value: descriptor.value,
148
+ revision: descriptor.revision,
149
+ }
150
+ }
151
+ return {
152
+ writable: true,
153
+ value: unwrapConfig(fallbackValue ?? {}),
154
+ revision: 0,
155
+ }
156
+ }
157
+
158
+ async function readTextFile(path) {
159
+ try {
160
+ const text = await readFile(path, 'utf8')
161
+ return text.trim() === '' ? undefined : text.trim()
162
+ } catch {
163
+ return undefined
164
+ }
165
+ }
166
+
167
+ /** The API Key lives in one fixed local file, never in the profile patch. */
168
+ async function resolveApiKey(overrideKey, keyPath = keyFilePath()) {
169
+ if (typeof overrideKey === 'string' && overrideKey.trim() !== '') return overrideKey.trim()
170
+ return readTextFile(keyPath)
171
+ }
172
+
173
+ async function saveJevKey(apiKey, keyPath = keyFilePath()) {
174
+ const key = typeof apiKey === 'string' ? apiKey.trim() : ''
175
+ if (key === '') throw new Error('task-models: API key must be non-empty')
176
+ await mkdir(dirname(keyPath), { recursive: true, mode: 0o700 })
177
+ await writeFile(keyPath, key, { encoding: 'utf8', mode: 0o600 })
178
+ return keyPath
179
+ }
180
+
181
+ function signalWithTimeout(signal, timeoutMs) {
182
+ const timeout = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined
183
+ if (signal === undefined || signal === null) return timeout
184
+ if (typeof AbortSignal.any === 'function' && timeout !== undefined) {
185
+ return AbortSignal.any([signal, timeout])
186
+ }
187
+ const controller = new AbortController()
188
+ const abort = (reason) => controller.abort(reason)
189
+ if (signal.aborted) abort(signal.reason)
190
+ else signal.addEventListener('abort', () => abort(signal.reason), { once: true })
191
+ if (timeout !== undefined) timeout.addEventListener('abort', () => abort(timeout.reason), { once: true })
192
+ return controller.signal
193
+ }
194
+
195
+ async function requestJev(jev, input, signal, overrideKey, keyPath = keyFilePath()) {
196
+ const state = input?.state
197
+ if (typeof state !== 'string' || state.trim() === '') {
198
+ throw new Error('task-models: Jev state must be non-empty')
199
+ }
200
+ const questions = input?.questions
201
+ if (questions === null || typeof questions !== 'object' || Array.isArray(questions)
202
+ || Object.keys(questions).length === 0) {
203
+ throw new Error('task-models: Jev questions must be a non-empty object')
204
+ }
205
+ const apiKey = await resolveApiKey(overrideKey, keyPath)
206
+ if (apiKey === undefined) {
207
+ const error = new Error(`task-models: no TypeSafe API key. Save one in Settings -> 任务模型 -> Jev (${keyPath}).`)
208
+ error.code = 'unconfigured'
209
+ throw error
210
+ }
211
+ const url = `${String(jev.baseURL).replace(/\/+$/u, '')}/v1/systemone`
212
+ let response
213
+ try {
214
+ response = await fetch(url, {
215
+ method: 'POST',
216
+ headers: {
217
+ 'content-type': 'application/json',
218
+ authorization: `Bearer ${apiKey}`,
219
+ },
220
+ body: JSON.stringify({ state, questions, model: jev.model }),
221
+ signal: signalWithTimeout(signal, jev.timeoutMs),
222
+ })
223
+ } catch (error) {
224
+ if (signal?.aborted === true) {
225
+ const aborted = new Error('task-models: Jev request was aborted by the caller')
226
+ aborted.name = 'AbortError'
227
+ aborted.code = 'aborted'
228
+ throw aborted
229
+ }
230
+ const name = error instanceof Error ? error.name : ''
231
+ const code = name === 'TimeoutError' || name === 'AbortError' ? 'timeout' : 'network'
232
+ const wrapped = new Error(`task-models: Jev request failed (${code}): ${error instanceof Error ? error.message : String(error)}`)
233
+ wrapped.code = code
234
+ throw wrapped
235
+ }
236
+ if (!response.ok) {
237
+ const detail = await response.text().catch(() => '')
238
+ const error = new Error(`task-models: Jev returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`)
239
+ error.code = `http-${response.status}`
240
+ throw error
241
+ }
242
+ const data = await response.json().catch((error) => {
243
+ const wrapped = new Error(`task-models: Jev returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
244
+ wrapped.code = 'invalid-response'
245
+ throw wrapped
246
+ })
247
+ if (data === null || typeof data !== 'object' || Array.isArray(data)
248
+ || data.answers === null || typeof data.answers !== 'object' || Array.isArray(data.answers)) {
249
+ const error = new Error('task-models: Jev response is missing the required answers object')
250
+ error.code = 'invalid-response'
251
+ throw error
252
+ }
253
+ return data
254
+ }
255
+
256
+ async function pingJev(jev, overrideKey, signal, keyPath = keyFilePath()) {
257
+ const startedAt = Date.now()
258
+ const data = await requestJev(jev, {
259
+ state: 'Ping check from DSH task-models',
260
+ questions: {
261
+ ping: { type: 'noul', instructions: JEV_PING_INSTRUCTIONS },
262
+ },
263
+ }, signal, overrideKey, keyPath)
264
+ return { latencyMs: Date.now() - startedAt, answers: data.answers }
265
+ }
266
+
267
+ /** Dedicated host RPC wire for a namespace hidden by DSH's settings allowlist. */
268
+ export async function handleSettingsRpc(settingsProvider, method, payload, currentSettings = {}, targetNs = NS, getChildModels, keyPath = keyFilePath()) {
269
+ const keys = payload === null || typeof payload !== 'object' || Array.isArray(payload)
270
+ ? null
271
+ : Reflect.ownKeys(payload)
272
+ if (keys === null) return badSettingsRequest('task-models settings requests must carry an object')
273
+
274
+ let current
275
+ try {
276
+ current = resolveConfig(currentSettings)
277
+ } catch {
278
+ current = resolveConfig({})
279
+ }
280
+
281
+ try {
282
+ if (method === 'describe') {
283
+ if (keys.length !== 0) return badSettingsRequest('task-models describe requests must carry an empty object')
284
+ return { ok: true, value: settingsRpcView(settingsProvider, currentSettings, targetNs) }
285
+ }
286
+ if (method === 'replace') {
287
+ if (keys.length !== 2 || !Object.hasOwn(payload, 'section') || !Object.hasOwn(payload, 'expectedRevision')) {
288
+ return badSettingsRequest('task-models replace requests require section and expectedRevision')
289
+ }
290
+ if (payload.section === null || typeof payload.section !== 'object' || Array.isArray(payload.section)) {
291
+ return badSettingsRequest('task-models section must be an object')
292
+ }
293
+ if (!Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) {
294
+ return badSettingsRequest('task-models expectedRevision must be a non-negative integer')
295
+ }
296
+ await settingsProvider.replace(NS, payload.section, payload.expectedRevision)
297
+ return { ok: true, value: settingsRpcView(settingsProvider, payload.section, targetNs) }
298
+ }
299
+ if (method === 'jevStatus') {
300
+ if (keys.length !== 0) return badSettingsRequest('task-models jevStatus requests must carry an empty object')
301
+ const apiKey = await resolveApiKey(undefined, keyPath)
302
+ return {
303
+ ok: true,
304
+ value: { configured: apiKey !== undefined, model: current.jev.model },
305
+ }
306
+ }
307
+ if (method === 'saveJevKey') {
308
+ if (keys.length !== 1 || !Object.hasOwn(payload, 'apiKey') || typeof payload.apiKey !== 'string') {
309
+ return badSettingsRequest('task-models saveJevKey requests require an apiKey string')
310
+ }
311
+ await saveJevKey(payload.apiKey, keyPath)
312
+ return { ok: true, value: { configured: true, model: current.jev.model } }
313
+ }
314
+ if (method === 'childModels') {
315
+ if (keys.length !== 1
316
+ || !Object.hasOwn(payload, 'childIds')
317
+ || !Array.isArray(payload.childIds)
318
+ || payload.childIds.length > 500
319
+ || payload.childIds.some((id) => typeof id !== 'string' || id === '')) {
320
+ return badSettingsRequest('task-models childModels requests require a childIds string array (max 500)')
321
+ }
322
+ const models = typeof getChildModels === 'function' ? await getChildModels(payload.childIds) : {}
323
+ return { ok: true, value: { models: models !== null && typeof models === 'object' ? models : {} } }
324
+ }
325
+ if (method === 'testJev') {
326
+ if (keys.length > 2
327
+ || (Object.hasOwn(payload, 'apiKey') && typeof payload.apiKey !== 'string')
328
+ || (Object.hasOwn(payload, 'jev') && (payload.jev === null || typeof payload.jev !== 'object' || Array.isArray(payload.jev)))) {
329
+ return badSettingsRequest('task-models testJev accepts an optional apiKey string and jev object')
330
+ }
331
+ const jevConfig = resolveConfig({
332
+ ...currentSettings,
333
+ jev: { ...current.jev, ...(typeof payload.jev === 'object' && payload.jev !== null ? payload.jev : {}) },
334
+ }).jev
335
+ const result = await pingJev(
336
+ jevConfig,
337
+ typeof payload.apiKey === 'string' ? payload.apiKey : undefined,
338
+ typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(5_000) : undefined,
339
+ keyPath,
340
+ )
341
+ return { ok: true, value: { latencyMs: result.latencyMs, answers: result.answers } }
342
+ }
343
+ return badSettingsRequest(`Unknown task-models settings method: ${method}`)
344
+ } catch (error) {
345
+ return {
346
+ ok: false,
347
+ error: {
348
+ code: typeof error?.code === 'string' ? error.code : 'internal',
349
+ message: error instanceof Error ? error.message : String(error),
350
+ details: {},
351
+ },
352
+ }
353
+ }
354
+ }
355
+
356
+ function promptTextOf(messages) {
357
+ if (!Array.isArray(messages)) return ''
358
+ const human = messages.filter((message) => message?.role === 'user'
359
+ && (message.source?.kind === undefined || message.source.kind === 'user'))
360
+ const selected = human.length > 0 ? human : messages
361
+ return selected
362
+ .flatMap((message) => (Array.isArray(message?.content) ? message.content : []))
363
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
364
+ .map((block) => block.text)
365
+ .join('\n')
366
+ .trim()
367
+ }
368
+
369
+ function toRoute(profile) {
370
+ if (profile?.provider === undefined || profile?.model === undefined) return undefined
371
+ return {
372
+ provider: profile.provider,
373
+ model: profile.model,
374
+ ...(profile.reasoningEffort === undefined || profile.reasoningEffort === ''
375
+ ? {}
376
+ : { reasoningEffort: profile.reasoningEffort }),
377
+ }
378
+ }
379
+
380
+ function applyRoute(config, route) {
381
+ const { reasoningEffort: _previousEffort, ...rest } = config
382
+ return {
383
+ ...rest,
384
+ provider: route.provider,
385
+ model: route.model,
386
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
387
+ }
388
+ }
389
+
390
+ /** Register settings, the Jev client, and the subagent request router. */
391
+ export function apply(ctx, config = {}) {
392
+ const scope = openSettingsScope(ctx, NS, Config, config)
393
+ let settings = unwrapConfig(scope.get())
394
+ let routingConfig
395
+ try {
396
+ routingConfig = resolveConfig(settings)
397
+ } catch (error) {
398
+ ctx.logger.warn?.('task-models: invalid settings; using defaults')
399
+ ctx.logger.warn?.(error)
400
+ routingConfig = resolveConfig({})
401
+ }
402
+
403
+ const firstStepText = new WeakMap()
404
+ const subagentLabels = new WeakMap()
405
+ const taskDecisions = new WeakMap()
406
+ const childModels = new Map()
407
+ const historicalModels = new Map()
408
+ let jevFailureLogged = false
409
+
410
+ /**
411
+ * Remember the effective route of every delegated child request. The
412
+ * workflow UI asks for these by child session id, so a member row can show
413
+ * the model that actually served the child even when another role router,
414
+ * an explicit subagent model, or this plugin selected it.
415
+ */
416
+ function rememberChildModel(agent, config) {
417
+ if (!isSubagent(agent)) return
418
+ const provider = typeof config?.provider === 'string' ? config.provider : ''
419
+ const model = typeof config?.model === 'string' ? config.model : ''
420
+ if (provider === '' || model === '') return
421
+ const id = agent?.session?.id ?? agent?.id
422
+ if (typeof id !== 'string' || id === '') return
423
+ const previous = childModels.get(id)
424
+ const next = Object.freeze({
425
+ provider,
426
+ model,
427
+ ...(typeof config?.reasoningEffort === 'string' && config.reasoningEffort !== ''
428
+ ? { reasoningEffort: config.reasoningEffort }
429
+ : {}),
430
+ })
431
+ if (previous !== undefined && previous.provider === next.provider
432
+ && previous.model === next.model && previous.reasoningEffort === next.reasoningEffort) {
433
+ return
434
+ }
435
+ childModels.delete(id)
436
+ childModels.set(id, next)
437
+ if (childModels.size > 2_000) {
438
+ const oldest = childModels.keys().next().value
439
+ if (oldest !== undefined) childModels.delete(oldest)
440
+ }
441
+ }
442
+
443
+ function modelsFor(childIds) {
444
+ const models = {}
445
+ for (const id of childIds) {
446
+ const entry = childModels.get(id)
447
+ if (entry !== undefined) models[id] = entry
448
+ }
449
+ return models
450
+ }
451
+
452
+ /**
453
+ * Best-effort backfill for workflow members whose child session predates
454
+ * this plugin instance (for example a run created before a restart). The
455
+ * durable child log records its last request/header, which contains the
456
+ * provider/model that actually served it. This is additive and never
457
+ * affects routing; a missing or unreadable log simply leaves the badge
458
+ * blank.
459
+ */
460
+ async function historicalModelOf(childId) {
461
+ if (typeof childId !== 'string' || childId === '.' || childId === '..' || childId.includes('..')
462
+ || !/^[A-Za-z0-9._:-]+$/u.test(childId)) return undefined
463
+ if (historicalModels.has(childId)) return historicalModels.get(childId) ?? undefined
464
+ let result
465
+ try {
466
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
467
+ const root = join(home, 'sessions')
468
+ const scopes = await readdir(root, { withFileTypes: true })
469
+ for (const scope of scopes) {
470
+ if (!scope.isDirectory()) continue
471
+ const base = join(root, scope.name, childId)
472
+ for (const name of ['session.v4.jsonl.zstd', 'session.v4.jsonl']) {
473
+ let raw
474
+ try {
475
+ raw = await readFile(join(base, name))
476
+ } catch {
477
+ continue
478
+ }
479
+ let text
480
+ if (name.endsWith('.zstd')) {
481
+ if (typeof zlib.zstdDecompressSync !== 'function') continue
482
+ try {
483
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
484
+ const offsets = []
485
+ let offset = raw.indexOf(magic)
486
+ while (offset !== -1) {
487
+ offsets.push(offset)
488
+ offset = raw.indexOf(magic, offset + 4)
489
+ }
490
+ const chunks = []
491
+ for (let index = 0; index < offsets.length; index += 1) {
492
+ const end = index + 1 < offsets.length ? offsets[index + 1] : raw.length
493
+ try {
494
+ chunks.push(zlib.zstdDecompressSync(raw.subarray(offsets[index], end)).toString('utf8'))
495
+ } catch {
496
+ // A malformed frame never invalidates the other frames.
497
+ }
498
+ }
499
+ text = chunks.join('')
500
+ } catch {
501
+ continue
502
+ }
503
+ } else {
504
+ text = raw.toString('utf8')
505
+ }
506
+ let found
507
+ for (const line of text.split(/\r?\n/u)) {
508
+ if (!line.includes('"request/header"')) continue
509
+ try {
510
+ const event = JSON.parse(line)
511
+ const config = event?.data?.header?.config ?? event?.data?.config
512
+ if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
513
+ found = {
514
+ provider: config.provider,
515
+ model: config.model,
516
+ ...(typeof config.reasoningEffort === 'string' && config.reasoningEffort !== ''
517
+ ? { reasoningEffort: config.reasoningEffort }
518
+ : {}),
519
+ }
520
+ }
521
+ } catch {
522
+ // Ignore a torn or non-JSON line.
523
+ }
524
+ }
525
+ if (found !== undefined) {
526
+ result = found
527
+ break
528
+ }
529
+ }
530
+ if (result !== undefined) break
531
+ }
532
+ } catch {
533
+ // Missing session storage is normal on a fresh machine.
534
+ }
535
+ historicalModels.set(childId, result ?? null)
536
+ if (historicalModels.size > 1_000) {
537
+ const oldest = historicalModels.keys().next().value
538
+ if (oldest !== undefined) historicalModels.delete(oldest)
539
+ }
540
+ return result
541
+ }
542
+
543
+ async function modelsForWithHistory(childIds) {
544
+ const models = modelsFor(childIds)
545
+ const missing = childIds.filter((id) => models[id] === undefined)
546
+ await Promise.all(missing.map(async (id) => {
547
+ const entry = await historicalModelOf(id)
548
+ if (entry !== undefined) models[id] = entry
549
+ }))
550
+ return models
551
+ }
552
+
553
+ function warnJevOnce(message, error) {
554
+ if (jevFailureLogged) return
555
+ jevFailureLogged = true
556
+ ctx.logger.warn?.(message)
557
+ if (error !== undefined) ctx.logger.warn?.(error)
558
+ }
559
+
560
+ function labelOf(agent) {
561
+ const session = agent?.session
562
+ if (session === undefined) return undefined
563
+ const cached = subagentLabels.get(session)
564
+ if (cached !== undefined) return cached
565
+ const events = Array.isArray(session.events) ? session.events : []
566
+ for (let index = events.length - 1; index >= 0; index -= 1) {
567
+ const event = events[index]
568
+ if (event?.type === 'subagent/descriptor' && typeof event.data?.label === 'string') {
569
+ subagentLabels.set(session, event.data.label)
570
+ return event.data.label
571
+ }
572
+ }
573
+ return undefined
574
+ }
575
+
576
+ function parentOf(agent) {
577
+ const id = agent?.session?.header?.parentSession
578
+ if (typeof id !== 'string' || id === '') return undefined
579
+ try {
580
+ return ctx.get?.('agents')?.get?.(id)
581
+ } catch {
582
+ return undefined
583
+ }
584
+ }
585
+
586
+ function requestHeaderOf(agent) {
587
+ try {
588
+ return agent?.session?.requestHeader?.()
589
+ } catch {
590
+ return undefined
591
+ }
592
+ }
593
+
594
+ function childHasExplicitRoute(agent) {
595
+ const parent = parentOf(agent)
596
+ if (parent === undefined) return false
597
+ let parentConfig = {}
598
+ try {
599
+ parentConfig = parent.session?.requestHeader?.()?.config ?? parent.options ?? {}
600
+ } catch {
601
+ parentConfig = parent.options ?? {}
602
+ }
603
+ return childRouteIsExplicit(agent?.options, parentConfig)
604
+ }
605
+
606
+ async function askJev(agent, cfg, label, text, signal) {
607
+ const parent = parentOf(agent)
608
+ const request = profileJevRequest({
609
+ parentTask: parent === undefined ? '' : taskTextOf(parent).slice(-2_000),
610
+ label,
611
+ text,
612
+ profiles: cfg.profiles,
613
+ maxStateChars: cfg.jev.maxStateChars,
614
+ })
615
+ const data = await requestJev(cfg.jev, request, signal)
616
+ return parseJevChoice(data, cfg.profiles, cfg.jev.minConfidence)
617
+ }
618
+
619
+ async function classifyWithLlm(cfg, label, text, signal) {
620
+ const route = cfg.routing.classifier
621
+ if (!route?.provider || !route?.model) return undefined
622
+ const task = [
623
+ typeof label === 'string' && label.trim() !== '' ? `Subtask label:\n${label}` : '',
624
+ typeof text === 'string' && text.trim() !== '' ? `<subtask>\n${text}\n</subtask>` : '',
625
+ ].filter(Boolean).join('\n\n').slice(-6_000)
626
+ if (task === '') return undefined
627
+ const assembler = new BlockAssembler()
628
+ for await (const chunk of ctx.llm.stream({
629
+ provider: route.provider,
630
+ model: route.model,
631
+ ...(route.reasoningEffort === '' ? {} : { reasoningEffort: route.reasoningEffort }),
632
+ messages: [createUserMessage({
633
+ content: [{ type: 'text', text: task }],
634
+ source: { kind: `plugin:${NS}` },
635
+ })],
636
+ system: profileSystem(cfg.profiles),
637
+ temperature: 0,
638
+ maxTokens: 128,
639
+ ...(signal === undefined ? {} : { signal }),
640
+ })) assembler.push(chunk)
641
+ const output = assembler.blocks()
642
+ .filter((block) => block?.type === 'text')
643
+ .map((block) => block.text)
644
+ .join('\n')
645
+ return parseProfile(output, cfg.profiles)
646
+ }
647
+
648
+ async function decideProfile(agent, cfg, label, text, signal) {
649
+ const deterministic = selectProfile(cfg.profiles, label, text)
650
+ if (deterministic !== undefined) return { profile: deterministic, source: 'rules' }
651
+
652
+ if (cfg.routing.useJev && cfg.jev.enabled) {
653
+ try {
654
+ const id = await askJev(agent, cfg, label, text, signal)
655
+ const profile = id === undefined ? undefined : cfg.profiles.get(id)
656
+ if (profile?.enabled) return { profile, source: 'jev' }
657
+ } catch (error) {
658
+ if (!(error?.name === 'AbortError' && signal?.aborted === true)) {
659
+ warnJevOnce('task-models: Jev task classification failed; falling back', error)
660
+ }
661
+ }
662
+ }
663
+
664
+ if (cfg.routing.fallback === 'llm') {
665
+ const id = await classifyWithLlm(cfg, label, text, signal)
666
+ const profile = id === undefined ? undefined : cfg.profiles.get(id)
667
+ if (profile?.enabled) return { profile, source: 'llm' }
668
+ }
669
+ return undefined
670
+ }
671
+
672
+ function decisionFor(agent, cfg, label, text, signal) {
673
+ const signature = `${label ?? ''}\u0000${text ?? ''}`
674
+ const cached = taskDecisions.get(agent)
675
+ if (cached !== undefined && cached.signature === signature) return cached.promise
676
+ const promise = decideProfile(agent, cfg, label, text, signal).catch((error) => {
677
+ ctx.logger.warn?.('task-models: task profile classification failed; keeping the current route')
678
+ ctx.logger.warn?.(error)
679
+ return undefined
680
+ })
681
+ taskDecisions.set(agent, { signature, promise })
682
+ return promise
683
+ }
684
+
685
+ async function routeUsable(route, signal) {
686
+ if (typeof ctx.llm?.resolveCallConfig !== 'function') return true
687
+ try {
688
+ await ctx.llm.resolveCallConfig(route, signal)
689
+ return true
690
+ } catch (error) {
691
+ ctx.logger.warn?.('task-models: selected profile route is not usable; keeping the current route')
692
+ ctx.logger.warn?.(error)
693
+ return false
694
+ }
695
+ }
696
+
697
+ scope.watch((next) => {
698
+ const value = unwrapConfig(next)
699
+ try {
700
+ const resolved = resolveConfig(value)
701
+ settings = value
702
+ routingConfig = resolved
703
+ } catch (error) {
704
+ ctx.logger.warn?.('task-models: keeping the last good settings after an invalid update')
705
+ ctx.logger.warn?.(error)
706
+ }
707
+ })
708
+
709
+ ctx.inject(['connection'], (connectionCtx) => {
710
+ const targetNs = settingsNamespace(ctx, NS)
711
+ connectionCtx.connection.rpc.handle(
712
+ SETTINGS_RPC_CHANNEL,
713
+ (method, payload) => handleSettingsRpc(
714
+ ctx.settings,
715
+ method,
716
+ payload,
717
+ settings,
718
+ targetNs,
719
+ (childIds) => modelsForWithHistory(childIds),
720
+ ),
721
+ { authority: 'trusted-host' },
722
+ )
723
+ })
724
+
725
+ // Capture the delegated prompt before agent/request: the Host commits the
726
+ // current step's user message only after the request waterfall.
727
+ ctx.on('agent/pre-step', async ({ agent, turn, step }, next) => {
728
+ const decision = await next()
729
+ if (decision.kind !== 'enter') return decision
730
+ if (isSubagent(agent) && turn === 1 && step === 1
731
+ && agent?.options?.modelRole === undefined && !firstStepText.has(agent)) {
732
+ const text = promptTextOf(decision.messages)
733
+ if (text !== '') firstStepText.set(agent, text.slice(-6_000))
734
+ }
735
+ return decision
736
+ }, { prepend: true })
737
+
738
+ ctx.on('session/event', (session, event) => {
739
+ if (event?.type === 'subagent/descriptor' && typeof event.data?.label === 'string') {
740
+ subagentLabels.set(session, event.data.label)
741
+ }
742
+ })
743
+
744
+ // Prepending makes this router the outermost agent/request listener: it
745
+ // observes every other listener's result through next() and applies its
746
+ // profile last. It only touches delegated children.
747
+ ctx.on('agent/request', async ({ agent, signal }, next) => {
748
+ const current = await next()
749
+ rememberChildModel(agent, current)
750
+ const cfg = routingConfig
751
+ if (!cfg.routing.enabled || cfg.profiles.size === 0) return current
752
+ if (!isSubagent(agent)) return current
753
+ if (agent?.options?.modelRole !== undefined) return current
754
+ if (planModeActive(agent?.session?.events)) return current
755
+
756
+ const label = labelOf(agent)
757
+ const captured = firstStepText.get(agent)
758
+ const text = typeof captured === 'string' && captured !== '' ? captured : taskTextOf(agent)
759
+ if ((label ?? '') === '' && (text ?? '') === '') return current
760
+
761
+ const firstRequest = requestHeaderOf(agent) === undefined
762
+ let decision
763
+ const cached = taskDecisions.get(agent)
764
+ if (cached !== undefined) {
765
+ decision = await cached.promise
766
+ } else {
767
+ if (!firstRequest) return current
768
+ if (cfg.routing.respectExplicitChildModel && childHasExplicitRoute(agent)) return current
769
+ decision = await decisionFor(agent, cfg, label, text, signal)
770
+ }
771
+
772
+ if (decision?.profile === undefined || !cfg.profiles.has(decision.profile.id)) return current
773
+ const route = toRoute(decision.profile)
774
+ if (route === undefined) return current
775
+ if (firstRequest && !(await routeUsable(route, signal))) return current
776
+ const routed = applyRoute(current, route)
777
+ rememberChildModel(agent, routed)
778
+ return routed
779
+ }, { prepend: true })
780
+ }