@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/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
- await settingsProvider.replace(NS, payload.section, payload.expectedRevision)
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 === '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)')
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(payload.childIds) : {}
323
- return { ok: true, value: { models: models !== null && typeof models === 'object' ? models : {} } }
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
- * 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.
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
- return parseJevChoice(data, cfg.profiles, cfg.jev.minConfidence)
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
- for await (const chunk of ctx.llm.stream({
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
- ...(signal === undefined ? {} : { signal }),
640
- })) assembler.push(chunk)
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
- const deterministic = selectProfile(cfg.profiles, label, text)
650
- if (deterministic !== undefined) return { profile: deterministic, source: 'rules' }
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 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
- }
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
- 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' }
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 decisionFor(agent, cfg, label, text, signal) {
673
- const signature = `${label ?? ''}\u0000${text ?? ''}`
881
+ function activeDecision(agent) {
674
882
  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)
883
+ if (cached?.pending && cached.signal?.aborted) {
884
+ taskDecisions.delete(agent)
679
885
  return undefined
680
- })
681
- taskDecisions.set(agent, { signature, promise })
682
- return promise
886
+ }
887
+ return cached
683
888
  }
684
889
 
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
- }
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 (!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)
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.2.1",
4
- "description": "Per-task-type subagent model routing for DeepSeek Harness, optional TypeSafe Jev decisions, and workflow member model labels",
3
+ "version": "0.3.0",
4
+ "description": "子任务模型路由插件:按任务类型为子代理选择模型,显式标签优先、规则试运行与路由解释,可选 TypeSafe Jev 决策与轻量分类回退,并区分预选与请求头确认的 workflow 成员模型。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {