@weniatall/dsh-task-models 0.2.1 → 0.3.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/README.md +32 -17
- package/cordis.patch.yml +1 -0
- package/lib/client.js +463 -99
- package/lib/core.js +133 -25
- package/lib/index.js +326 -85
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import zlib from 'node:zlib'
|
|
|
18
18
|
import {
|
|
19
19
|
DEFAULT_JEV,
|
|
20
20
|
DEFAULT_ROUTING,
|
|
21
|
+
analyzeRules,
|
|
21
22
|
childRouteIsExplicit,
|
|
22
23
|
isSubagent,
|
|
23
24
|
parseJevChoice,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
profileJevRequest,
|
|
27
28
|
profileSystem,
|
|
28
29
|
resolveConfig,
|
|
29
|
-
selectProfile,
|
|
30
30
|
taskTextOf,
|
|
31
31
|
} from './core.js'
|
|
32
32
|
|
|
@@ -111,6 +111,7 @@ export const Config = z.object({
|
|
|
111
111
|
fallback: z.string().default('taskRole'),
|
|
112
112
|
useJev: z.boolean().default(true),
|
|
113
113
|
respectExplicitChildModel: z.boolean().default(true),
|
|
114
|
+
decisionTimeoutMs: z.number().step(1).min(100).max(60_000).default(DEFAULT_ROUTING.decisionTimeoutMs),
|
|
114
115
|
classifier: routeEntry.default({}),
|
|
115
116
|
}).default({})),
|
|
116
117
|
profiles: liveField(z.array(profileEntry).default([])),
|
|
@@ -203,6 +204,7 @@ async function requestJev(jev, input, signal, overrideKey, keyPath = keyFilePath
|
|
|
203
204
|
throw new Error('task-models: Jev questions must be a non-empty object')
|
|
204
205
|
}
|
|
205
206
|
const apiKey = await resolveApiKey(overrideKey, keyPath)
|
|
207
|
+
throwIfAborted(signal)
|
|
206
208
|
if (apiKey === undefined) {
|
|
207
209
|
const error = new Error(`task-models: no TypeSafe API key. Save one in Settings -> 任务模型 -> Jev (${keyPath}).`)
|
|
208
210
|
error.code = 'unconfigured'
|
|
@@ -264,8 +266,40 @@ async function pingJev(jev, overrideKey, signal, keyPath = keyFilePath()) {
|
|
|
264
266
|
return { latencyMs: Date.now() - startedAt, answers: data.answers }
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/** Pure, zero-model-call preview shared with the live router's rule analysis. */
|
|
270
|
+
export function previewRules(cfg, label, text) {
|
|
271
|
+
const ruleAnalysis = analyzeRules(cfg.profiles, label, text)
|
|
272
|
+
if (!cfg.routing.enabled) return { ruleAnalysis, next: 'current', reason: 'routing-disabled' }
|
|
273
|
+
if (![...cfg.profiles.values()].some((profile) => profile.enabled)) {
|
|
274
|
+
return { ruleAnalysis, next: 'current', reason: 'no-profiles' }
|
|
275
|
+
}
|
|
276
|
+
if (label.trim() === '' && text.trim() === '') return { ruleAnalysis, next: 'current', reason: 'no-task' }
|
|
277
|
+
const profile = cfg.profiles.get(ruleAnalysis.profileId)
|
|
278
|
+
if (profile !== undefined) {
|
|
279
|
+
return {
|
|
280
|
+
ruleAnalysis,
|
|
281
|
+
profile: { id: profile.id, name: profile.name, ...toRoute(profile) },
|
|
282
|
+
next: 'rules',
|
|
283
|
+
reason: ruleAnalysis.source === 'explicit-label' ? 'rules-explicit-label' : 'rules-keywords',
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (cfg.routing.useJev && cfg.jev.enabled) return { ruleAnalysis, next: 'jev', reason: 'rules-unmatched' }
|
|
287
|
+
if (cfg.routing.fallback === 'llm') {
|
|
288
|
+
return cfg.routing.classifier.provider && cfg.routing.classifier.model
|
|
289
|
+
? { ruleAnalysis, next: 'llm', reason: 'rules-unmatched' }
|
|
290
|
+
: { ruleAnalysis, next: 'current', reason: 'classifier-unconfigured' }
|
|
291
|
+
}
|
|
292
|
+
return { ruleAnalysis, next: 'current', reason: ruleAnalysis.source === 'tie' ? 'rules-tie' : 'no-match' }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validChildIds(payload, keys) {
|
|
296
|
+
return keys.length === 1 && Object.hasOwn(payload, 'childIds')
|
|
297
|
+
&& Array.isArray(payload.childIds) && payload.childIds.length <= 500
|
|
298
|
+
&& payload.childIds.every((id) => typeof id === 'string' && id !== '')
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
/** 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()) {
|
|
302
|
+
export async function handleSettingsRpc(settingsProvider, method, payload, currentSettings = {}, targetNs = NS, getChildModels, keyPath = keyFilePath(), getRoutingDetails) {
|
|
269
303
|
const keys = payload === null || typeof payload !== 'object' || Array.isArray(payload)
|
|
270
304
|
? null
|
|
271
305
|
: Reflect.ownKeys(payload)
|
|
@@ -293,7 +327,12 @@ export async function handleSettingsRpc(settingsProvider, method, payload, curre
|
|
|
293
327
|
if (!Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) {
|
|
294
328
|
return badSettingsRequest('task-models expectedRevision must be a non-negative integer')
|
|
295
329
|
}
|
|
296
|
-
|
|
330
|
+
try {
|
|
331
|
+
resolveConfig(payload.section)
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return badSettingsRequest(error instanceof Error ? error.message : 'Invalid task-models settings')
|
|
334
|
+
}
|
|
335
|
+
await settingsProvider.replace(targetNs, payload.section, payload.expectedRevision)
|
|
297
336
|
return { ok: true, value: settingsRpcView(settingsProvider, payload.section, targetNs) }
|
|
298
337
|
}
|
|
299
338
|
if (method === 'jevStatus') {
|
|
@@ -311,16 +350,34 @@ export async function handleSettingsRpc(settingsProvider, method, payload, curre
|
|
|
311
350
|
await saveJevKey(payload.apiKey, keyPath)
|
|
312
351
|
return { ok: true, value: { configured: true, model: current.jev.model } }
|
|
313
352
|
}
|
|
314
|
-
if (method === '
|
|
315
|
-
if (keys.
|
|
316
|
-
||
|
|
317
|
-
||
|
|
318
|
-
|| payload.
|
|
319
|
-
|
|
320
|
-
return badSettingsRequest('task-models
|
|
353
|
+
if (method === 'previewRules') {
|
|
354
|
+
if (keys.some((key) => !['label', 'text', 'section'].includes(key))
|
|
355
|
+
|| typeof payload.label !== 'string' || payload.label.length > 300
|
|
356
|
+
|| typeof payload.text !== 'string' || payload.text.length > 6_000
|
|
357
|
+
|| (Object.hasOwn(payload, 'section') && (payload.section === null
|
|
358
|
+
|| typeof payload.section !== 'object' || Array.isArray(payload.section)))) {
|
|
359
|
+
return badSettingsRequest('task-models previewRules requires label (max 300 chars), text (max 6000 chars), and an optional section object')
|
|
360
|
+
}
|
|
361
|
+
let cfg
|
|
362
|
+
try {
|
|
363
|
+
cfg = resolveConfig(Object.hasOwn(payload, 'section') ? payload.section : currentSettings)
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return badSettingsRequest(error instanceof Error ? error.message : 'Invalid task-models preview settings')
|
|
366
|
+
}
|
|
367
|
+
return { ok: true, value: previewRules(cfg, payload.label, payload.text) }
|
|
368
|
+
}
|
|
369
|
+
if (method === 'childModels' || method === 'routingDetails') {
|
|
370
|
+
if (!validChildIds(payload, keys)) {
|
|
371
|
+
return badSettingsRequest(`task-models ${method} requests require a childIds string array (max 500)`)
|
|
372
|
+
}
|
|
373
|
+
const childIds = [...new Set(payload.childIds)]
|
|
374
|
+
if (method === 'routingDetails' && typeof getRoutingDetails === 'function') {
|
|
375
|
+
return { ok: true, value: await getRoutingDetails(childIds) }
|
|
321
376
|
}
|
|
322
|
-
const models = typeof getChildModels === 'function' ? await getChildModels(
|
|
323
|
-
|
|
377
|
+
const models = typeof getChildModels === 'function' ? await getChildModels(childIds) : {}
|
|
378
|
+
const value = { models: models !== null && typeof models === 'object' ? models : {} }
|
|
379
|
+
if (method === 'routingDetails') value.details = {}
|
|
380
|
+
return { ok: true, value }
|
|
324
381
|
}
|
|
325
382
|
if (method === 'testJev') {
|
|
326
383
|
if (keys.length > 2
|
|
@@ -387,6 +444,30 @@ function applyRoute(config, route) {
|
|
|
387
444
|
}
|
|
388
445
|
}
|
|
389
446
|
|
|
447
|
+
function cancellationError() {
|
|
448
|
+
const error = new Error('task-models: routing decision was cancelled by the caller')
|
|
449
|
+
error.name = 'AbortError'
|
|
450
|
+
error.code = 'aborted'
|
|
451
|
+
return error
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function throwIfAborted(signal) {
|
|
455
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : cancellationError()
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Bound even an adapter that ignores AbortSignal, without leaking listeners. */
|
|
459
|
+
function waitWithSignal(value, signal) {
|
|
460
|
+
const promise = Promise.resolve(value)
|
|
461
|
+
if (signal === undefined) return promise
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const cleanup = () => signal.removeEventListener('abort', abort)
|
|
464
|
+
const abort = () => { cleanup(); reject(signal.reason instanceof Error ? signal.reason : cancellationError()) }
|
|
465
|
+
promise.then((result) => { cleanup(); resolve(result) }, (error) => { cleanup(); reject(error) })
|
|
466
|
+
if (signal.aborted) abort()
|
|
467
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
390
471
|
/** Register settings, the Jev client, and the subagent request router. */
|
|
391
472
|
export function apply(ctx, config = {}) {
|
|
392
473
|
const scope = openSettingsScope(ctx, NS, Config, config)
|
|
@@ -404,16 +485,17 @@ export function apply(ctx, config = {}) {
|
|
|
404
485
|
const subagentLabels = new WeakMap()
|
|
405
486
|
const taskDecisions = new WeakMap()
|
|
406
487
|
const childModels = new Map()
|
|
488
|
+
const effectiveChildModels = new Map()
|
|
489
|
+
const routingDetails = new Map()
|
|
407
490
|
const historicalModels = new Map()
|
|
408
491
|
let jevFailureLogged = false
|
|
409
492
|
|
|
410
493
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* an explicit subagent model, or this plugin selected it.
|
|
494
|
+
* Preserve the legacy proposal map for childModels. The new routingDetails
|
|
495
|
+
* surface separately tracks only committed request/header routes as effective;
|
|
496
|
+
* neither map claims the provider has already returned a successful response.
|
|
415
497
|
*/
|
|
416
|
-
function rememberChildModel(agent, config) {
|
|
498
|
+
function rememberChildModel(agent, config, effective = false) {
|
|
417
499
|
if (!isSubagent(agent)) return
|
|
418
500
|
const provider = typeof config?.provider === 'string' ? config.provider : ''
|
|
419
501
|
const model = typeof config?.model === 'string' ? config.model : ''
|
|
@@ -428,6 +510,11 @@ export function apply(ctx, config = {}) {
|
|
|
428
510
|
? { reasoningEffort: config.reasoningEffort }
|
|
429
511
|
: {}),
|
|
430
512
|
})
|
|
513
|
+
if (effective) {
|
|
514
|
+
effectiveChildModels.delete(id)
|
|
515
|
+
effectiveChildModels.set(id, next)
|
|
516
|
+
if (effectiveChildModels.size > 2_000) effectiveChildModels.delete(effectiveChildModels.keys().next().value)
|
|
517
|
+
}
|
|
431
518
|
if (previous !== undefined && previous.provider === next.provider
|
|
432
519
|
&& previous.model === next.model && previous.reasoningEffort === next.reasoningEffort) {
|
|
433
520
|
return
|
|
@@ -441,7 +528,7 @@ export function apply(ctx, config = {}) {
|
|
|
441
528
|
}
|
|
442
529
|
|
|
443
530
|
function modelsFor(childIds) {
|
|
444
|
-
const models =
|
|
531
|
+
const models = Object.create(null)
|
|
445
532
|
for (const id of childIds) {
|
|
446
533
|
const entry = childModels.get(id)
|
|
447
534
|
if (entry !== undefined) models[id] = entry
|
|
@@ -449,6 +536,29 @@ export function apply(ctx, config = {}) {
|
|
|
449
536
|
return models
|
|
450
537
|
}
|
|
451
538
|
|
|
539
|
+
function rememberRoutingDetail(agent, decision) {
|
|
540
|
+
const id = agent?.session?.id ?? agent?.id
|
|
541
|
+
if (typeof id !== 'string' || id === '') return
|
|
542
|
+
const { route: _route, ...detail } = decision
|
|
543
|
+
routingDetails.delete(id)
|
|
544
|
+
routingDetails.set(id, Object.freeze(detail))
|
|
545
|
+
if (routingDetails.size > 2_000) routingDetails.delete(routingDetails.keys().next().value)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function detailsFor(childIds) {
|
|
549
|
+
return Object.fromEntries(childIds.flatMap((id) => {
|
|
550
|
+
const detail = routingDetails.get(id)
|
|
551
|
+
if (detail === undefined) return []
|
|
552
|
+
const effectiveRoute = effectiveChildModels.get(id)
|
|
553
|
+
return [[id, { ...detail, ...(effectiveRoute === undefined ? {} : { effectiveRoute }) }]]
|
|
554
|
+
}))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function keepCurrent(agent, current, reason) {
|
|
558
|
+
rememberRoutingDetail(agent, { status: 'current', source: 'current', reason, durationMs: 0 })
|
|
559
|
+
return current
|
|
560
|
+
}
|
|
561
|
+
|
|
452
562
|
/**
|
|
453
563
|
* Best-effort backfill for workflow members whose child session predates
|
|
454
564
|
* this plugin instance (for example a run created before a restart). The
|
|
@@ -604,6 +714,7 @@ export function apply(ctx, config = {}) {
|
|
|
604
714
|
}
|
|
605
715
|
|
|
606
716
|
async function askJev(agent, cfg, label, text, signal) {
|
|
717
|
+
throwIfAborted(signal)
|
|
607
718
|
const parent = parentOf(agent)
|
|
608
719
|
const request = profileJevRequest({
|
|
609
720
|
parentTask: parent === undefined ? '' : taskTextOf(parent).slice(-2_000),
|
|
@@ -612,20 +723,33 @@ export function apply(ctx, config = {}) {
|
|
|
612
723
|
profiles: cfg.profiles,
|
|
613
724
|
maxStateChars: cfg.jev.maxStateChars,
|
|
614
725
|
})
|
|
615
|
-
const data = await requestJev(cfg.jev, request, signal)
|
|
616
|
-
|
|
726
|
+
const data = await waitWithSignal(requestJev(cfg.jev, request, signal), signal)
|
|
727
|
+
const id = parseJevChoice(data, cfg.profiles, cfg.jev.minConfidence)
|
|
728
|
+
let answer = data.answers?.task_profile
|
|
729
|
+
if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
|
|
730
|
+
answer = Object.values(data.answers ?? {}).find((entry) => entry !== null
|
|
731
|
+
&& typeof entry === 'object' && !Array.isArray(entry) && 'choice' in entry)
|
|
732
|
+
}
|
|
733
|
+
const confidence = answer?.confidence
|
|
734
|
+
if (id !== undefined) return { id, confidence }
|
|
735
|
+
const validConfidence = typeof confidence === 'number' && Number.isFinite(confidence) && confidence >= 0 && confidence <= 1
|
|
736
|
+
return {
|
|
737
|
+
reason: !validConfidence ? 'jev-invalid-response'
|
|
738
|
+
: confidence < cfg.jev.minConfidence ? 'jev-low-confidence'
|
|
739
|
+
: answer?.choice === 'general' ? 'no-match' : 'jev-invalid-response',
|
|
740
|
+
}
|
|
617
741
|
}
|
|
618
742
|
|
|
619
743
|
async function classifyWithLlm(cfg, label, text, signal) {
|
|
744
|
+
throwIfAborted(signal)
|
|
620
745
|
const route = cfg.routing.classifier
|
|
621
|
-
if (!route?.provider || !route?.model) return undefined
|
|
622
746
|
const task = [
|
|
623
747
|
typeof label === 'string' && label.trim() !== '' ? `Subtask label:\n${label}` : '',
|
|
624
748
|
typeof text === 'string' && text.trim() !== '' ? `<subtask>\n${text}\n</subtask>` : '',
|
|
625
749
|
].filter(Boolean).join('\n\n').slice(-6_000)
|
|
626
750
|
if (task === '') return undefined
|
|
627
751
|
const assembler = new BlockAssembler()
|
|
628
|
-
|
|
752
|
+
const iterator = ctx.llm.stream({
|
|
629
753
|
provider: route.provider,
|
|
630
754
|
model: route.model,
|
|
631
755
|
...(route.reasoningEffort === '' ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
@@ -636,8 +760,25 @@ export function apply(ctx, config = {}) {
|
|
|
636
760
|
system: profileSystem(cfg.profiles),
|
|
637
761
|
temperature: 0,
|
|
638
762
|
maxTokens: 128,
|
|
639
|
-
|
|
640
|
-
})
|
|
763
|
+
signal,
|
|
764
|
+
})[Symbol.asyncIterator]()
|
|
765
|
+
let finish
|
|
766
|
+
try {
|
|
767
|
+
while (true) {
|
|
768
|
+
throwIfAborted(signal)
|
|
769
|
+
const chunk = await waitWithSignal(iterator.next(), signal)
|
|
770
|
+
if (chunk.done) break
|
|
771
|
+
assembler.push(chunk.value)
|
|
772
|
+
if (chunk.value.type === 'finish') finish = chunk.value.reason?.kind
|
|
773
|
+
}
|
|
774
|
+
} finally {
|
|
775
|
+
// An uncooperative stream must not hold the routing deadline hostage.
|
|
776
|
+
if (signal.aborted && typeof iterator.return === 'function') {
|
|
777
|
+
try { void Promise.resolve(iterator.return()).catch(() => {}) } catch { /* Best effort. */ }
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
throwIfAborted(signal)
|
|
781
|
+
if (finish !== 'stop') throw new Error('task-models: classifier did not finish successfully')
|
|
641
782
|
const output = assembler.blocks()
|
|
642
783
|
.filter((block) => block?.type === 'text')
|
|
643
784
|
.map((block) => block.text)
|
|
@@ -645,53 +786,123 @@ export function apply(ctx, config = {}) {
|
|
|
645
786
|
return parseProfile(output, cfg.profiles)
|
|
646
787
|
}
|
|
647
788
|
|
|
648
|
-
async function decideProfile(agent, cfg, label, text, signal) {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
789
|
+
async function decideProfile(agent, cfg, label, text, signal, ruleAnalysis) {
|
|
790
|
+
throwIfAborted(signal)
|
|
791
|
+
const deterministic = cfg.profiles.get(ruleAnalysis.profileId)
|
|
792
|
+
if (deterministic !== undefined) {
|
|
793
|
+
return {
|
|
794
|
+
profile: deterministic, source: 'rules',
|
|
795
|
+
reason: ruleAnalysis.source === 'explicit-label' ? 'rules-explicit-label' : 'rules-keywords',
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
let reason = ruleAnalysis.source === 'tie' ? 'rules-tie' : 'no-match'
|
|
652
799
|
if (cfg.routing.useJev && cfg.jev.enabled) {
|
|
653
800
|
try {
|
|
654
|
-
const
|
|
655
|
-
const profile =
|
|
656
|
-
if (profile?.enabled) return { profile, source: 'jev' }
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
801
|
+
const result = await askJev(agent, cfg, label, text, signal)
|
|
802
|
+
const profile = cfg.profiles.get(result.id)
|
|
803
|
+
if (profile?.enabled) return { profile, source: 'jev', reason: 'jev-selected', confidence: result.confidence }
|
|
804
|
+
reason = result.reason ?? 'no-match'
|
|
805
|
+
} catch {
|
|
806
|
+
throwIfAborted(signal)
|
|
807
|
+
reason = 'jev-unavailable'
|
|
808
|
+
warnJevOnce('task-models: Jev task classification failed; falling back')
|
|
661
809
|
}
|
|
662
810
|
}
|
|
663
|
-
|
|
811
|
+
throwIfAborted(signal)
|
|
664
812
|
if (cfg.routing.fallback === 'llm') {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
813
|
+
if (!cfg.routing.classifier.provider || !cfg.routing.classifier.model) return { reason: 'classifier-unconfigured' }
|
|
814
|
+
try {
|
|
815
|
+
const id = await classifyWithLlm(cfg, label, text, signal)
|
|
816
|
+
const profile = cfg.profiles.get(id)
|
|
817
|
+
if (profile?.enabled) return { profile, source: 'llm', reason: 'llm-selected' }
|
|
818
|
+
reason = 'no-match'
|
|
819
|
+
} catch {
|
|
820
|
+
throwIfAborted(signal)
|
|
821
|
+
reason = 'classifier-failed'
|
|
822
|
+
ctx.logger.warn?.('task-models: lightweight classification failed; keeping the current route')
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return { reason }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function validatedDecision(agent, cfg, label, text, callerSignal) {
|
|
829
|
+
const startedAt = Date.now()
|
|
830
|
+
const ruleAnalysis = analyzeRules(cfg.profiles, label, text)
|
|
831
|
+
const controller = new AbortController()
|
|
832
|
+
const abort = () => controller.abort(cancellationError())
|
|
833
|
+
if (callerSignal?.aborted) throw cancellationError()
|
|
834
|
+
callerSignal?.addEventListener('abort', abort, { once: true })
|
|
835
|
+
const timer = setTimeout(() => {
|
|
836
|
+
const error = new Error('task-models: routing decision budget exhausted')
|
|
837
|
+
error.code = 'decision-timeout'
|
|
838
|
+
controller.abort(error)
|
|
839
|
+
}, cfg.routing.decisionTimeoutMs)
|
|
840
|
+
const signal = controller.signal
|
|
841
|
+
let selected
|
|
842
|
+
try {
|
|
843
|
+
selected = await decideProfile(agent, cfg, label, text, signal, ruleAnalysis)
|
|
844
|
+
throwIfAborted(signal)
|
|
845
|
+
if (selected.profile === undefined) {
|
|
846
|
+
return { status: 'current', source: 'current', reason: selected.reason, ruleAnalysis, durationMs: Date.now() - startedAt }
|
|
847
|
+
}
|
|
848
|
+
const route = Object.freeze(toRoute(selected.profile))
|
|
849
|
+
if (typeof ctx.llm?.resolveCallConfig === 'function') {
|
|
850
|
+
try {
|
|
851
|
+
await waitWithSignal(ctx.llm.resolveCallConfig(route, signal), signal)
|
|
852
|
+
} catch {
|
|
853
|
+
throwIfAborted(signal)
|
|
854
|
+
return {
|
|
855
|
+
status: 'current', source: selected.source, reason: 'route-unusable',
|
|
856
|
+
profileId: selected.profile.id, profileName: selected.profile.name,
|
|
857
|
+
selectedRoute: route, ruleAnalysis, durationMs: Date.now() - startedAt,
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
throwIfAborted(signal)
|
|
862
|
+
return {
|
|
863
|
+
status: 'adopted', source: selected.source, reason: selected.reason,
|
|
864
|
+
profileId: selected.profile.id, profileName: selected.profile.name,
|
|
865
|
+
...(selected.confidence === undefined ? {} : { confidence: selected.confidence }),
|
|
866
|
+
selectedRoute: route, route, ruleAnalysis, durationMs: Date.now() - startedAt,
|
|
867
|
+
}
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (callerSignal?.aborted) throw cancellationError()
|
|
870
|
+
return {
|
|
871
|
+
status: 'current', source: 'current',
|
|
872
|
+
reason: signal.aborted && signal.reason?.code === 'decision-timeout' ? 'decision-timeout' : 'classifier-failed',
|
|
873
|
+
ruleAnalysis, durationMs: Date.now() - startedAt,
|
|
874
|
+
}
|
|
875
|
+
} finally {
|
|
876
|
+
clearTimeout(timer)
|
|
877
|
+
callerSignal?.removeEventListener('abort', abort)
|
|
668
878
|
}
|
|
669
|
-
return undefined
|
|
670
879
|
}
|
|
671
880
|
|
|
672
|
-
function
|
|
673
|
-
const signature = `${label ?? ''}\u0000${text ?? ''}`
|
|
881
|
+
function activeDecision(agent) {
|
|
674
882
|
const cached = taskDecisions.get(agent)
|
|
675
|
-
if (cached
|
|
676
|
-
|
|
677
|
-
ctx.logger.warn?.('task-models: task profile classification failed; keeping the current route')
|
|
678
|
-
ctx.logger.warn?.(error)
|
|
883
|
+
if (cached?.pending && cached.signal?.aborted) {
|
|
884
|
+
taskDecisions.delete(agent)
|
|
679
885
|
return undefined
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
return promise
|
|
886
|
+
}
|
|
887
|
+
return cached
|
|
683
888
|
}
|
|
684
889
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
890
|
+
function decisionFor(agent, cfg, label, text, signal) {
|
|
891
|
+
const cached = activeDecision(agent)
|
|
892
|
+
if (cached !== undefined) return cached.promise
|
|
893
|
+
const entry = { signal, pending: true }
|
|
894
|
+
entry.promise = validatedDecision(agent, cfg, label, text, signal).then((result) => {
|
|
895
|
+
entry.pending = false
|
|
896
|
+
return result
|
|
897
|
+
}, (error) => {
|
|
898
|
+
// Cancellation is not an abstention: the next uncancelled first request
|
|
899
|
+
// may retry classification. An old rejection must not evict a newer retry.
|
|
900
|
+
entry.pending = false
|
|
901
|
+
if (taskDecisions.get(agent) === entry) taskDecisions.delete(agent)
|
|
902
|
+
throw error
|
|
903
|
+
})
|
|
904
|
+
taskDecisions.set(agent, entry)
|
|
905
|
+
return entry.promise
|
|
695
906
|
}
|
|
696
907
|
|
|
697
908
|
scope.watch((next) => {
|
|
@@ -717,6 +928,19 @@ export function apply(ctx, config = {}) {
|
|
|
717
928
|
settings,
|
|
718
929
|
targetNs,
|
|
719
930
|
(childIds) => modelsForWithHistory(childIds),
|
|
931
|
+
keyFilePath(),
|
|
932
|
+
async (childIds) => {
|
|
933
|
+
const entries = await Promise.all(childIds.map(async (id) => {
|
|
934
|
+
// Never treat the legacy proposal map as confirmed, including while
|
|
935
|
+
// the first classifier is still pending and no detail exists yet.
|
|
936
|
+
let model = effectiveChildModels.get(id)
|
|
937
|
+
if (model === undefined && !childModels.has(id) && !routingDetails.has(id)) {
|
|
938
|
+
model = await historicalModelOf(id)
|
|
939
|
+
}
|
|
940
|
+
return model === undefined ? [] : [[id, model]]
|
|
941
|
+
}))
|
|
942
|
+
return { models: Object.fromEntries(entries.flat()), details: detailsFor(childIds) }
|
|
943
|
+
},
|
|
720
944
|
),
|
|
721
945
|
{ authority: 'trusted-host' },
|
|
722
946
|
)
|
|
@@ -739,6 +963,9 @@ export function apply(ctx, config = {}) {
|
|
|
739
963
|
if (event?.type === 'subagent/descriptor' && typeof event.data?.label === 'string') {
|
|
740
964
|
subagentLabels.set(session, event.data.label)
|
|
741
965
|
}
|
|
966
|
+
if (event?.type === 'request/header') {
|
|
967
|
+
rememberChildModel({ session }, event.data?.header?.config ?? event.data?.config, true)
|
|
968
|
+
}
|
|
742
969
|
})
|
|
743
970
|
|
|
744
971
|
// Prepending makes this router the outermost agent/request listener: it
|
|
@@ -746,34 +973,48 @@ export function apply(ctx, config = {}) {
|
|
|
746
973
|
// profile last. It only touches delegated children.
|
|
747
974
|
ctx.on('agent/request', async ({ agent, signal }, next) => {
|
|
748
975
|
const current = await next()
|
|
976
|
+
if (!isSubagent(agent)) return current
|
|
977
|
+
throwIfAborted(signal)
|
|
749
978
|
rememberChildModel(agent, current)
|
|
750
979
|
const cfg = routingConfig
|
|
751
|
-
if (
|
|
752
|
-
if (
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
980
|
+
if (agent?.options?.modelRole !== undefined) return keepCurrent(agent, current, 'internal-role')
|
|
981
|
+
if (planModeActive(agent?.session?.events)) return keepCurrent(agent, current, 'plan-mode')
|
|
982
|
+
if (!cfg.routing.enabled) return keepCurrent(agent, current, 'routing-disabled')
|
|
983
|
+
|
|
984
|
+
// An adopted snapshot survives edits, disablement and removal of its profile.
|
|
985
|
+
// Only the explicit global switch and the existing bypasses suspend replay.
|
|
986
|
+
let cached = activeDecision(agent)
|
|
987
|
+
if (cached === undefined) {
|
|
988
|
+
if (![...cfg.profiles.values()].some((profile) => profile.enabled)) return keepCurrent(agent, current, 'no-profiles')
|
|
989
|
+
if (requestHeaderOf(agent) !== undefined) return keepCurrent(agent, current, 'existing-request')
|
|
990
|
+
if (cfg.routing.respectExplicitChildModel && childHasExplicitRoute(agent)) return keepCurrent(agent, current, 'explicit-child-model')
|
|
991
|
+
const label = labelOf(agent)
|
|
992
|
+
const captured = firstStepText.get(agent)
|
|
993
|
+
const text = typeof captured === 'string' && captured !== '' ? captured : taskTextOf(agent)
|
|
994
|
+
if ((label ?? '') === '' && (text ?? '') === '') return keepCurrent(agent, current, 'no-task')
|
|
995
|
+
decisionFor(agent, cfg, label, text, signal)
|
|
996
|
+
cached = taskDecisions.get(agent)
|
|
997
|
+
}
|
|
998
|
+
const decision = await waitWithSignal(cached.promise, signal)
|
|
999
|
+
throwIfAborted(signal)
|
|
1000
|
+
// A validated candidate is not yet an adopted route. If a bypass appears
|
|
1001
|
+
// during classification, never activate that unused candidate on re-enable.
|
|
1002
|
+
const bypass = !routingConfig.routing.enabled ? 'routing-disabled'
|
|
1003
|
+
: agent?.options?.modelRole !== undefined ? 'internal-role'
|
|
1004
|
+
: planModeActive(agent?.session?.events) ? 'plan-mode' : undefined
|
|
1005
|
+
if (bypass !== undefined) {
|
|
1006
|
+
if (!cached.applied) {
|
|
1007
|
+
cached.promise = Promise.resolve({
|
|
1008
|
+
status: 'current', source: 'current', reason: bypass,
|
|
1009
|
+
ruleAnalysis: decision.ruleAnalysis, durationMs: decision.durationMs,
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
return keepCurrent(agent, current, bypass)
|
|
1013
|
+
}
|
|
1014
|
+
rememberRoutingDetail(agent, decision)
|
|
1015
|
+
if (decision.route === undefined) return current
|
|
1016
|
+
const routed = applyRoute(current, decision.route)
|
|
1017
|
+
cached.applied = true
|
|
777
1018
|
rememberChildModel(agent, routed)
|
|
778
1019
|
return routed
|
|
779
1020
|
}, { prepend: true })
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weniatall/dsh-task-models",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "子任务模型路由插件:按任务类型为子代理选择模型,显式标签优先、规则试运行与路由解释,可选 TypeSafe Jev 决策与轻量分类回退,并区分预选与请求头确认的 workflow 成员模型。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|