@ljwei-stak/dsh-model-router 0.14.0 → 0.16.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.
@@ -12,9 +12,11 @@ import {
12
12
  DEFAULT_COOLDOWN_MINUTES, billingOverview, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
13
13
  } from './shared/subscription-billing.mjs'
14
14
  import { createRouterState } from './shared/router-state.mjs'
15
+ import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
16
+ import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, routingData } from './shared/dynamic-data.mjs'
15
17
  import {
16
- applyQualityBiases, budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
17
- routeQualityBiases, spending, storedResults, actualCost, teamExecutionResults,
18
+ budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
19
+ spending, storedResults, actualCost, teamExecutionResults,
18
20
  } from './shared/run-ledger.mjs'
19
21
  import { routeBoundaries } from './shared/security-boundaries.mjs'
20
22
  import { createPlanFromRoutes } from './shared/harness-plan.mjs'
@@ -62,6 +64,15 @@ export const Config = z.object({
62
64
  overBudgetAction: z.union(['downgrade', 'pause']).default('downgrade').volatile(),
63
65
  reviewMode: z.union(['off', 'sample', 'always']).default('off').volatile(),
64
66
  reviewSampleRate: z.number().min(0).max(1).default(0.2).volatile(),
67
+ feedbackLearningEnabled: z.boolean().default(true).volatile(),
68
+ feedbackHalfLifeDays: z.number().min(1).max(3650).default(30).volatile(),
69
+ feedbackPriorWeight: z.number().min(1).max(1_000_000).default(3).volatile(),
70
+ feedbackMaxAdjustment: z.number().min(0).max(0.1).default(0.04).volatile(),
71
+ feedbackResetAt: z.number().min(0).default(0).volatile(),
72
+ dynamicDataEnabled: z.boolean().default(false).volatile(),
73
+ liveBenchEndpoint: z.string().max(2048).default('https://livebench.ai').volatile(),
74
+ pricingSnapshotEndpoint: z.string().max(2048).default('').volatile(),
75
+ dynamicDataTtlMinutes: z.number().min(1).max(10_080).default(1440).volatile(),
65
76
  allowManualReassign: z.boolean().default(true).volatile(),
66
77
  confirmUnsandboxedCli: z.boolean().default(true).volatile(),
67
78
  subscriptionCooldownMinutes: z.number().step(1).min(1).max(10_080).default(DEFAULT_COOLDOWN_MINUTES).volatile(),
@@ -331,9 +342,9 @@ function budgetSettings(config) {
331
342
  }
332
343
  }
333
344
 
334
- function budgetFor(config, runs, estimateUsd) {
345
+ function budgetFor(config, saved, estimateUsd) {
335
346
  const settings = budgetSettings(config)
336
- const spent = spending(runs)
347
+ const spent = spending(saved.runs, undefined, saved.archivedSpending)
337
348
  return { ...budgetCheck({ estimateUsd, spent, ...settings }), spent, ...settings }
338
349
  }
339
350
 
@@ -350,13 +361,70 @@ export function billingForResult(result) {
350
361
  }
351
362
 
352
363
  function routePricing(routes, item) {
353
- return routes.find(route => route.provider === item?.provider && route.model === item?.model)?.pricing ?? null
364
+ const route = routes.find(route => route.provider === item?.provider && route.model === item?.model)
365
+ return route?.pricing ? { ...route.pricing, source: route.pricingSource ?? 'route', version: route.pricingVersion ?? null, asOf: route.snapshotAsOf ?? null } : null
354
366
  }
355
367
 
356
- /** Routes with user profiles and the gentle quality bias learned from ratings and reviews. */
357
- async function routesWithLearning(ctx, config, signal, runs) {
368
+ function feedbackOptions(config) {
369
+ return {
370
+ enabled: valueOf(config, 'feedbackLearningEnabled', true) !== false,
371
+ halfLifeDays: valueOf(config, 'feedbackHalfLifeDays', 30),
372
+ priorWeight: valueOf(config, 'feedbackPriorWeight', 3),
373
+ maxAdjustment: valueOf(config, 'feedbackMaxAdjustment', 0.04),
374
+ resetAt: valueOf(config, 'feedbackResetAt', 0),
375
+ }
376
+ }
377
+
378
+ function dynamicOptions(config) {
379
+ return {
380
+ enabled: valueOf(config, 'dynamicDataEnabled', false) === true,
381
+ liveBenchEndpoint: valueOf(config, 'liveBenchEndpoint', 'https://livebench.ai'),
382
+ pricingSnapshotEndpoint: valueOf(config, 'pricingSnapshotEndpoint', ''),
383
+ ttlMs: boundedInteger(valueOf(config, 'dynamicDataTtlMinutes', 1440), 1440, 1, 10_080) * 60_000,
384
+ }
385
+ }
386
+
387
+ let dynamicRefresher = null
388
+ async function routingSavedState(config) {
389
+ const options = dynamicOptions(config)
390
+ let refreshedData = null
391
+ if (options.enabled) {
392
+ dynamicRefresher ??= createDynamicDataRefresher({ store: routerStateStore() })
393
+ // A public-data failure must never turn into a paid probe or prevent
394
+ // ordinary routing. The refresher preserves last-good snapshots.
395
+ try { refreshedData = await dynamicRefresher.refresh(options) } catch { /* state/read errors use the normal fallback */ }
396
+ }
397
+ const saved = await savedState()
398
+ // The refresher's projection excludes actively disabled sources while
399
+ // preserving their last-good snapshots on disk for recovery.
400
+ if (refreshedData) saved.dynamicData = refreshedData
401
+ if (saved.dynamicData) {
402
+ saved.dynamicData = { ...saved.dynamicData, status: { ...saved.dynamicData.status } }
403
+ for (const [kind, endpoint] of [['pricing', options.pricingSnapshotEndpoint], ['liveBench', options.liveBenchEndpoint]]) {
404
+ if (!text(endpoint)) {
405
+ saved.dynamicData[kind] = null
406
+ saved.dynamicData.status[kind] = { disabled: true, endpoint: '', error: '' }
407
+ }
408
+ }
409
+ }
410
+ saved.routingLearning = buildFeedbackProfile(saved.runs, feedbackOptions(config))
411
+ return saved
412
+ }
413
+
414
+ function planDataOptions(saved, config) {
415
+ return {
416
+ ...routingData(saved.dynamicData, dynamicOptions(config).enabled),
417
+ learning: saved.routingLearning ?? buildFeedbackProfile(saved.runs, feedbackOptions(config)),
418
+ }
419
+ }
420
+
421
+ /** Manual exact-route profiles take precedence over public price snapshots.
422
+ * Only explicit subjective feedback affects utility; reviews stay separate. */
423
+ async function routesWithLearning(ctx, config, signal, runs, dynamicData = null, feedbackProfile = null) {
358
424
  const discovered = await discoverConfiguredRoutes(ctx, signal)
359
- return applyQualityBiases(configuredRoutesWithProfiles(discovered, config), routeQualityBiases(runs))
425
+ const profiled = configuredRoutesWithProfiles(discovered, config)
426
+ const priced = applyPricingSnapshot(profiled, dynamicOptions(config).enabled ? dynamicData?.pricing : null)
427
+ return applyFeedbackProfile(priced, feedbackProfile ?? buildFeedbackProfile(runs, feedbackOptions(config)))
360
428
  }
361
429
 
362
430
  /** Produce a route recommendation and work packages compatible with official Agent Teams. */
@@ -366,9 +434,9 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
366
434
  const mode = options.mode === 'team' ? 'team' : 'single'
367
435
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
368
436
  const budgetUsd = Math.max(0, finiteNumber(options.budgetUsd, finiteNumber(configuredBudget, 0)))
369
- const saved = await savedState()
437
+ const saved = await routingSavedState(config)
370
438
  const [availableRoutes, installed] = await Promise.all([
371
- routesWithLearning(ctx, config, options.signal, saved.runs),
439
+ routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning),
372
440
  options.skipToolProbe === true
373
441
  ? Promise.resolve(Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
374
442
  : installedToolIds(),
@@ -381,12 +449,13 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
381
449
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
382
450
  : loggedOutIds(await currentHealth())
383
451
  const plan = createPlanFromRoutes(taskText, availableRoutes, {
452
+ ...planDataOptions(saved, config),
384
453
  mode, budgetUsd, installedToolIds: installed,
385
454
  runnableToolIds: installed.filter(id => executable.has(id)),
386
455
  preset: routingPresetOf(config, options.preset),
387
456
  loggedOutToolIds: loggedOut,
388
457
  })
389
- return { ...plan, budgetStatus: budgetFor(config, saved.runs, plan.estimatedCost) }
458
+ return { ...plan, budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
390
459
  }
391
460
 
392
461
  /** One bounded, independent call through the same official LLM service. */
@@ -599,8 +668,8 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
599
668
  if (problem) throw new Error(problem)
600
669
  const direct = text(options.provider) || text(options.model)
601
670
  if (direct && (!text(options.provider) || !text(options.model))) throw new Error('指定模型需要同时提供 provider 和 model。')
602
- const saved = await savedState()
603
- const routes = await routesWithLearning(ctx, config, options.signal, saved.runs)
671
+ const saved = await routingSavedState(config)
672
+ const routes = await routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning)
604
673
  if (direct && !routes.some(route => route.provider === text(options.provider) && route.model === text(options.model))) {
605
674
  throw new Error(`模型 ${text(options.provider)}/${text(options.model)} 不在 Harness 模型目录中;请在模型页添加后重试,或改用自动路由。`)
606
675
  }
@@ -618,18 +687,20 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
618
687
  const preset = routingPresetOf(config, options.preset)
619
688
  const planWith = (presetId, budgetUsd) => direct
620
689
  ? createPlanFromRoutes(taskText, routes, {
690
+ ...planDataOptions(saved, config),
621
691
  mode: 'direct', directProvider: options.provider, directModel: options.model,
622
692
  installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
623
693
  })
624
694
  : createPlanFromRoutes(taskText, routes, {
695
+ ...planDataOptions(saved, config),
625
696
  mode: options.planMode === 'team' ? 'team' : 'single',
626
697
  budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
627
698
  })
628
699
  let plan = planWith(preset, options.budgetUsd)
629
- let budget = budgetFor(config, saved.runs, plan.estimatedCost)
700
+ let budget = budgetFor(config, saved, plan.estimatedCost)
630
701
  if (budget.exceeded && budget.action === 'downgrade' && !direct) {
631
702
  const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
632
- const recheck = budgetFor(config, saved.runs, cheaper.estimatedCost)
703
+ const recheck = budgetFor(config, saved, cheaper.estimatedCost)
633
704
  if (!recheck.exceeded) {
634
705
  budget = { ...recheck, downgraded: true, downgradedFrom: preset,
635
706
  message: `原方案${budget.message} 已自动降级为“省钱优先”方案(预估 ${cheaper.estimatedCost === null ? '价格待配置' : formatUsd(cheaper.estimatedCost)})。` }
@@ -741,7 +812,8 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
741
812
  if (choice && !['api', 'subscription', 'cancel'].includes(choice)) throw new Error('subscriptionChoice 只能是 api、subscription 或 cancel')
742
813
  if (choice && !target.paused) throw new Error('该步骤没有在等待订阅失败的确认;直接重跑即可。')
743
814
  if (choice === 'cancel') return { run: await cancelPausedStep(run.id, target.id), execution: null, budget: null, cancelled: true }
744
- const routes = await routesWithLearning(ctx, config, signal, saved.runs)
815
+ const refreshed = await routingSavedState(config)
816
+ const routes = await routesWithLearning(ctx, config, signal, refreshed.runs, refreshed.dynamicData, refreshed.routingLearning)
745
817
  let override = null
746
818
  if (text(provider) || text(model)) {
747
819
  if (!text(provider) || !text(model)) throw new Error('改派需要同时提供 provider 和 model')
@@ -768,7 +840,7 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
768
840
  budget: null, message: '续跑可编辑团队步骤会在新的独立 Git 工作树中套用之前的改动并修改文件,需要先确认。' }
769
841
  }
770
842
  const sameRoute = !override || (override.provider === target.recommendedProvider && override.model === target.recommendedModel)
771
- const budget = budgetFor(config, saved.runs, sameRoute ? target.estimatedCost : null)
843
+ const budget = budgetFor(config, saved, sameRoute ? target.estimatedCost : null)
772
844
  if (budget.exceeded && confirmOverBudget !== true) {
773
845
  return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
774
846
  }
@@ -954,7 +1026,8 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
954
1026
  if (text(args.provider) || text(args.model)) {
955
1027
  if (!text(args.provider) || !text(args.model)) throw new Error('provider 和 model 必须同时提供')
956
1028
  if (toolForProvider(args.provider)?.id !== toolId) throw new Error('所选模型供应商与官方 CLI 工具不匹配')
957
- routes = configuredRoutesWithProfiles(await discoverConfiguredRoutes(ctx, signal), config)
1029
+ const refreshed = await routingSavedState(config)
1030
+ routes = await routesWithLearning(ctx, config, signal, refreshed.runs, refreshed.dynamicData, refreshed.routingLearning)
958
1031
  route = routes.find(item => item.provider === text(args.provider) && item.model === text(args.model)) ?? null
959
1032
  if (!route) throw new Error('所选 provider/model 不在官方模型目录中')
960
1033
  modelId = route.cliModel && toolId !== 'zcode'
@@ -967,7 +1040,7 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
967
1040
  modelId = text(args.cliModel)
968
1041
  }
969
1042
  const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
970
- const budget = budgetFor(config, (await savedState()).runs, Number.isFinite(estimate) ? estimate : null)
1043
+ const budget = budgetFor(config, await savedState(), Number.isFinite(estimate) ? estimate : null)
971
1044
  return { toolId, tool, taskText, mode, modelId, route, routes, estimate: Number.isFinite(estimate) ? estimate : null, budget,
972
1045
  provider: route?.provider ?? '', model: route?.model ?? '', cliModel: text(args.cliModel) || null }
973
1046
  }
@@ -1000,20 +1073,29 @@ export async function recordToolRun({ toolId, task, provider, model, cliModel =
1000
1073
  }
1001
1074
 
1002
1075
  /** Store a user rating (+1 useful, -1 not useful, 0 clears) for one result. */
1003
- export async function rateRecordedResult({ runId, packageId, rating } = {}) {
1076
+ export async function rateRecordedResult({ runId, packageId, rating, expectedFinishedAt } = {}, { origin = 'local-explicit-input' } = {}) {
1004
1077
  const value = rating === 'up' || rating === 1 ? 1 : rating === 'down' || rating === -1 ? -1 : rating === 'clear' || rating === 0 ? null : undefined
1005
1078
  if (value === undefined) throw new Error('rating 只能是 up、down 或 clear')
1006
1079
  return routerStateStore().updateRun(text(runId), current => {
1007
1080
  const item = current.packages.find(entry => entry.id === text(packageId))
1008
1081
  if (!item) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
1082
+ if (expectedFinishedAt !== undefined && (!Number.isFinite(expectedFinishedAt) || expectedFinishedAt !== item.finishedAt)) {
1083
+ throw new Error('结果已更新,请刷新执行记录后再评价。')
1084
+ }
1085
+ if (value !== null && (item.ran !== true || item.ok !== true || item.paused || item.waiting || item.blocked)) {
1086
+ throw new Error('只能评价已成功执行的结果;失败和暂停不自动视为不满意。')
1087
+ }
1009
1088
  item.rating = value
1089
+ item.ratedAt = Date.now()
1090
+ item.feedbackPolicyVersion = 'subjective-utility-v1'
1091
+ item.feedbackOrigin = ['desktop-ui', 'approved-tool'].includes(origin) ? origin : 'local-explicit-input'
1010
1092
  })
1011
1093
  }
1012
1094
 
1013
1095
  /** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
1014
1096
  export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1015
- const saved = await savedState()
1016
- const spent = spending(saved.runs)
1097
+ const saved = await routingSavedState(config)
1098
+ const spent = spending(saved.runs, undefined, saved.archivedSpending)
1017
1099
  const settings = budgetSettings(config)
1018
1100
  return {
1019
1101
  runs: saved.runs.slice(-limit).reverse().map(run => ({
@@ -1023,7 +1105,14 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1023
1105
  })),
1024
1106
  spent,
1025
1107
  budget: { ...settings, ...budgetCheck({ estimateUsd: null, spent, ...settings }) },
1026
- biases: routeQualityBiases(saved.runs),
1108
+ // Legacy scalar quality biases are intentionally no longer learned.
1109
+ biases: {},
1110
+ learning: saved.routingLearning,
1111
+ dynamicData: dynamicDataSummary(saved.dynamicData, { ...dynamicOptions(config), now: Date.now() }),
1112
+ routingData: {
1113
+ ...routingData(saved.dynamicData, dynamicOptions(config).enabled),
1114
+ pricingSnapshot: dynamicOptions(config).enabled ? saved.dynamicData?.pricing ?? null : null,
1115
+ },
1027
1116
  onboarding: saved.onboarding,
1028
1117
  settings: {
1029
1118
  preset: routingPresetOf(config),
@@ -1308,7 +1397,7 @@ export function routerRemoteServices(ctx, config, { terminals = null } = {}) {
1308
1397
  },
1309
1398
  completeOnboarding: async () => routerStateStore().completeOnboarding(Date.now()),
1310
1399
  ledger: () => ledgerSummary(config),
1311
- rate: request => rateRecordedResult(request),
1400
+ rate: request => rateRecordedResult(request, { origin: 'desktop-ui' }),
1312
1401
  rerun: request => rerunRecordedStep(ctx, config, {
1313
1402
  runId: request?.runId, packageId: request?.packageId, provider: request?.provider, model: request?.model,
1314
1403
  confirmOverBudget: request?.confirmOverBudget === true, confirmWrite: request?.confirmWrite === true,
@@ -1337,6 +1426,10 @@ export function apply(ctx, config = {}) {
1337
1426
  ctx.on('tools/pre-execute', async (exec, next) => {
1338
1427
  const decision = await next()
1339
1428
  if (decision.kind !== 'allow') return decision
1429
+ if (exec.name === 'model_router_rate') {
1430
+ return { kind: 'ask', reason: 'Confirm the user\'s subjective rating; agents must not self-rate their results',
1431
+ displayReason: { en: 'Save this as your rating and adjust future recommendations?', zh: '确认这是你对该结果的评价,并用于调整以后的推荐?Agent 不能代替你给自己评分。' } }
1432
+ }
1340
1433
  if (exec.name === 'model_router_tool_install') {
1341
1434
  const requested = getOfficialTool(text(exec.arguments?.tool))
1342
1435
  const label = requested?.label ?? '官方 CLI'
@@ -1645,15 +1738,16 @@ function registerOfficialToolModels(ctx, config) {
1645
1738
  }))
1646
1739
  ctx.tools.register(defineTool({
1647
1740
  name: 'model_router_rate',
1648
- description: 'Record the user\'s rating of one recorded result (up = useful, down = not useful, clear). Ratings gently adjust future routing for that exact provider/model.',
1741
+ description: 'Record only the user\'s explicitly stated rating of one recorded result (up, down, clear). Requires approval; never self-rate. Feedback adjusts bounded local subjective utility, not objective quality.',
1649
1742
  parameters: {
1650
1743
  runId: { type: 'string', required: true, description: 'runId returned by model_router_execute.' },
1651
1744
  packageId: { type: 'string', required: true, description: 'Work package id.' },
1652
1745
  rating: { type: 'string', required: true, enum: ['up', 'down', 'clear'], description: 'The user\'s rating.' },
1746
+ expectedFinishedAt: { type: 'number', description: 'Completion timestamp of the displayed result; rejects stale feedback after a rerun.' },
1653
1747
  },
1654
1748
  output: JSON_OUTPUT,
1655
1749
  async execute(args) {
1656
- const run = await rateRecordedResult(args)
1750
+ const run = await rateRecordedResult(args, { origin: 'approved-tool' })
1657
1751
  return jsonValue({ ok: true, runId: run.id, packageId: args.packageId, rating: args.rating })
1658
1752
  },
1659
1753
  }))
@@ -1672,8 +1766,8 @@ function registerOfficialToolModels(ctx, config) {
1672
1766
  const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
1673
1767
  const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
1674
1768
  if (mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑团队执行')
1675
- const [discoveredRoutes, installed] = await Promise.all([discoverConfiguredRoutes(ctx, exec.signal), installedToolIds()])
1676
- const routes = configuredRoutesWithProfiles(discoveredRoutes, config)
1769
+ const saved = await routingSavedState(config)
1770
+ const [routes, installed] = await Promise.all([routesWithLearning(ctx, config, exec.signal, saved.runs, saved.dynamicData, saved.routingLearning), installedToolIds()])
1677
1771
  const readiness = await Promise.all(installed.map(id => officialToolReadiness(id, cwd)))
1678
1772
  const supported = new Set(readiness.filter(item => item.ready).map(item => item.id))
1679
1773
  const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
@@ -1688,11 +1782,12 @@ function registerOfficialToolModels(ctx, config) {
1688
1782
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1689
1783
  const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
1690
1784
  const plan = createPlanFromRoutes(args.task, executableRoutes, {
1785
+ ...planDataOptions(saved, config),
1691
1786
  mode: 'team', budgetUsd, installedToolIds: installed,
1692
1787
  runnableToolIds: installed.filter(id => supported.has(id)),
1693
1788
  preset: routingPresetOf(config),
1694
1789
  })
1695
- const budget = budgetFor(config, (await savedState()).runs, plan.estimatedCost)
1790
+ const budget = budgetFor(config, await savedState(), plan.estimatedCost)
1696
1791
  if (budget.exceeded && args.confirmOverBudget !== true) {
1697
1792
  return jsonValue({ status: 'paused-budget', budget, plan,
1698
1793
  notice: `${budget.message} 已暂停团队执行。请向用户确认后以 confirmOverBudget: true 重新调用。` })
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Bounded, local subjective-utility personalization. This is not a learned
3
+ * answer-correctness probability, a bandit policy, or an unbiased evaluation.
4
+ * Only explicit human feedback on an executed, successful result is evidence.
5
+ * Model reviews and missing feedback are deliberately not user satisfaction.
6
+ */
7
+ export const FEEDBACK_POLICY_VERSION = 'subjective-utility-v1'
8
+ export const FEEDBACK_RUN_WINDOW = 200
9
+
10
+ const DAY_MS = 86_400_000
11
+ const finite = value => typeof value === 'number' && Number.isFinite(value)
12
+ const clean = value => typeof value === 'string' ? value.trim() : ''
13
+ const routeKey = (provider, model) => `${provider}\u0000${model}`
14
+ // Code-unit ordering avoids machine/browser locale changing cache identity.
15
+ const compare = (left, right) => left === right ? 0 : left < right ? -1 : 1
16
+
17
+ function boundedOption(options, key, fallback, minimum, maximum = Infinity) {
18
+ const value = options[key] === undefined ? fallback : options[key]
19
+ if (!finite(value) || value < minimum || value > maximum) {
20
+ throw new RangeError(`${key} must be a finite number in [${minimum}, ${maximum}]`)
21
+ }
22
+ return value
23
+ }
24
+
25
+ function normalizedOptions(options) {
26
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('options must be an object')
27
+ const enabled = options.enabled === undefined ? true : options.enabled
28
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled must be boolean')
29
+ return {
30
+ enabled,
31
+ now: boundedOption(options, 'now', Date.now(), 0),
32
+ halfLifeDays: boundedOption(options, 'halfLifeDays', 30, 1, 3650),
33
+ priorWeight: boundedOption(options, 'priorWeight', 3, 1, 1_000_000),
34
+ maxAdjustment: boundedOption(options, 'maxAdjustment', 0.04, 0, 0.1),
35
+ resetAt: boundedOption(options, 'resetAt', 0, 0),
36
+ }
37
+ }
38
+
39
+ /** FNV-1a over JS UTF-16 code units: a short cache identity, NOT a security hash. */
40
+ function cacheRevision(value) {
41
+ const serialized = JSON.stringify(value)
42
+ let hash = 0x811c9dc5
43
+ for (let index = 0; index < serialized.length; index += 1) {
44
+ hash = Math.imul(hash ^ serialized.charCodeAt(index), 0x01000193) >>> 0
45
+ }
46
+ return `${FEEDBACK_POLICY_VERSION}:${hash.toString(16).padStart(8, '0')}`
47
+ }
48
+
49
+ function completedAt(run, item) {
50
+ // Stored packages have their own completion time. A historical run may have
51
+ // only a run-level completion timestamp, then its creation timestamp.
52
+ // Invalid explicitly supplied timestamps are not repaired by a fallback.
53
+ for (const candidate of [item.finishedAt, run.finishedAt, run.createdAt]) {
54
+ if (candidate === undefined || candidate === null) continue
55
+ return finite(candidate) && candidate >= 0 ? candidate : null
56
+ }
57
+ return null
58
+ }
59
+
60
+ function evidenceOf(run, item, options) {
61
+ const provider = clean(item.provider)
62
+ const model = clean(item.model)
63
+ if (!provider || !model || provider.includes('\u0000') || model.includes('\u0000') || item.ran !== true || item.ok !== true
64
+ || item.blocked === true || item.waiting === true || item.paused === true
65
+ || item.cancelled === true || (item.status !== undefined && !['succeeded', 'fallback'].includes(item.status))) return null
66
+ if (item.rating !== 1 && item.rating !== -1) return null
67
+ // A CLI default or a different actual model must not train the recommended
68
+ // model. Aliases are not silently equated; a future verified adapter may do so.
69
+ if (item.channel === 'official-cli' && clean(item.actualModel) !== model) return null
70
+ const finishedAt = completedAt(run, item)
71
+ if (finishedAt === null || finishedAt > options.now || finishedAt <= options.resetAt) return null
72
+ if (item.ratedAt !== undefined && item.ratedAt !== null
73
+ && (!finite(item.ratedAt) || item.ratedAt < 0 || item.ratedAt > options.now)) return null
74
+ return {
75
+ runId: run.id,
76
+ packageId: item.id,
77
+ provider,
78
+ model,
79
+ domain: clean(item.type) || 'general',
80
+ rating: item.rating,
81
+ finishedAt,
82
+ ratedAt: item.ratedAt ?? finishedAt,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Build a pure profile from the newest 200 runs under one local DSH home.
88
+ * Last occurrence of the same run/package replaces earlier occurrences BEFORE
89
+ * validation: a clear, retry, or invalid latest result removes old evidence.
90
+ * Existing metadata without ran/ok/identifiers/timestamps is not guessed.
91
+ *
92
+ * In one exact provider/model/domain cell:
93
+ * w_i = 2 ** (-(now - completedAt_i) / halfLifeMs)
94
+ * bias = maxAdjustment * sum(w_i * rating_i) / (priorWeight + sum(w_i))
95
+ * Ratings are revised in place, never extra independent observations. Decay
96
+ * uses result completion, so re-rating an old result cannot refresh its age or
97
+ * bypass resetAt. No cross-domain/model-version/user backoff is performed.
98
+ */
99
+ export function buildFeedbackProfile(runs, options = {}) {
100
+ const settings = normalizedOptions(options)
101
+ const profile = {
102
+ enabled: settings.enabled,
103
+ scope: 'local-dsh-home',
104
+ policyVersion: FEEDBACK_POLICY_VERSION,
105
+ feedbackCount: 0,
106
+ ignoredCount: 0,
107
+ effectiveWeight: 0,
108
+ updatedAt: null,
109
+ halfLifeDays: settings.halfLifeDays,
110
+ priorWeight: settings.priorWeight,
111
+ maxAdjustment: settings.maxAdjustment,
112
+ resetAt: settings.resetAt,
113
+ window: 'last-200-runs',
114
+ adjustments: {},
115
+ revision: '',
116
+ }
117
+ const latest = new Map()
118
+ if (settings.enabled) {
119
+ for (const run of (Array.isArray(runs) ? runs : []).slice(-FEEDBACK_RUN_WINDOW)) {
120
+ for (const item of Array.isArray(run?.packages) ? run.packages : []) {
121
+ if (!item || typeof item !== 'object' || !clean(run?.id) || !clean(item.id)) {
122
+ profile.ignoredCount += 1
123
+ continue
124
+ }
125
+ const identity = JSON.stringify([run.id, item.id])
126
+ if (latest.has(identity)) profile.ignoredCount += 1
127
+ latest.set(identity, { run, item })
128
+ }
129
+ }
130
+ }
131
+ const events = [...latest.values()].map(({ run, item }) => evidenceOf(run, item, settings))
132
+ .filter(event => {
133
+ if (!event) profile.ignoredCount += 1
134
+ return event !== null
135
+ }).sort((left, right) => compare(JSON.stringify([left.runId, left.packageId]), JSON.stringify([right.runId, right.packageId])))
136
+ const cells = new Map()
137
+ for (const event of events) {
138
+ const weight = 2 ** (-(settings.now - event.finishedAt) / (settings.halfLifeDays * DAY_MS))
139
+ const key = routeKey(event.provider, event.model)
140
+ const domains = cells.get(key) ?? new Map()
141
+ const cell = domains.get(event.domain) ?? { sum: 0, weight: 0 }
142
+ cell.sum += weight * event.rating
143
+ cell.weight += weight
144
+ domains.set(event.domain, cell)
145
+ cells.set(key, domains)
146
+ profile.feedbackCount += 1
147
+ profile.effectiveWeight += weight
148
+ profile.updatedAt = Math.max(profile.updatedAt ?? 0, event.ratedAt)
149
+ }
150
+ // Object.fromEntries safely creates own keys even for opaque identifiers
151
+ // such as '__proto__'; no task text or answer is copied into this profile.
152
+ profile.adjustments = Object.fromEntries([...cells.entries()].sort(([left], [right]) => compare(left, right))
153
+ .map(([key, domains]) => [key, Object.fromEntries([...domains.entries()].sort(([left], [right]) => compare(left, right))
154
+ .map(([domain, cell]) => [domain, Math.max(-settings.maxAdjustment, Math.min(settings.maxAdjustment,
155
+ settings.maxAdjustment * cell.sum / (settings.priorWeight + cell.weight)))]))]))
156
+ profile.revision = cacheRevision({ settings, events })
157
+ return profile
158
+ }
159
+
160
+ /**
161
+ * Add separate preference metadata; never overwrite benchmark quality/source.
162
+ * Unknown/catalog quality remains unknown/catalog evidence after applying this
163
+ * profile. The planner must consume the domain-specific adjustment explicitly.
164
+ */
165
+ export function applyFeedbackProfile(routes, profile) {
166
+ if (!Array.isArray(routes) || profile?.enabled !== true || !profile.adjustments
167
+ || typeof profile.adjustments !== 'object' || Array.isArray(profile.adjustments)) return routes
168
+ return routes.map(route => {
169
+ if (!route || typeof route !== 'object') return route
170
+ const key = routeKey(clean(route.provider), clean(route.model))
171
+ if (!Object.hasOwn(profile.adjustments, key)) return route
172
+ const value = profile.adjustments[key]
173
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return route
174
+ const adjustments = Object.fromEntries(Object.entries(value).filter(([, bias]) => finite(bias) && Math.abs(bias) <= 0.1))
175
+ if (!Object.keys(adjustments).length) return route
176
+ return {
177
+ ...route,
178
+ preferenceAdjustments: adjustments,
179
+ preferenceAdjustmentMeta: {
180
+ policyVersion: profile.policyVersion,
181
+ revision: profile.revision,
182
+ scope: profile.scope,
183
+ feedbackCount: profile.feedbackCount,
184
+ effectiveWeight: profile.effectiveWeight,
185
+ updatedAt: profile.updatedAt,
186
+ halfLifeDays: profile.halfLifeDays,
187
+ maxAdjustment: profile.maxAdjustment,
188
+ window: profile.window,
189
+ },
190
+ }
191
+ })
192
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Deterministic budget-constrained DAG assignment, independent of model names.
3
+ * Exact enumeration is bounded by the retained Cartesian candidate space;
4
+ * larger spaces use a beam with a protected least-cost feasible prefix.
5
+ * Optimality concerns this surrogate objective, never actual answer quality.
6
+ */
7
+ const EPSILON = 1e-12
8
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
9
+ const signature = state => JSON.stringify(state.choices.map(option => option.route))
10
+
11
+ function compareStates(left, right, minimizeCost = false) {
12
+ return left.relaxedCount - right.relaxedCount
13
+ || left.qualityShortfall - right.qualityShortfall
14
+ || (minimizeCost ? left.cost - right.cost : right.score - left.score)
15
+ || (minimizeCost ? right.score - left.score : left.cost - right.cost)
16
+ || left.switches - right.switches
17
+ || compareText(signature(left), signature(right))
18
+ }
19
+
20
+ export function solveCandidateAssignments({ tasks = [], pools = [], budget = Infinity, beamWidth = 256, exactLimit = 4096, handoffPenalty = 0.015, minimizeCost = false } = {}) {
21
+ if (!tasks.length || tasks.length !== pools.length) return null
22
+ const preceding = new Set()
23
+ for (const task of tasks) {
24
+ if (!task.id || preceding.has(task.id) || (task.dependsOn ?? []).some(id => !preceding.has(id))) {
25
+ throw new RangeError('Assignment tasks must have unique ids and known dependencies in topological order')
26
+ }
27
+ preceding.add(task.id)
28
+ }
29
+ const constrained = Number.isFinite(budget)
30
+ const options = pools.map(pool => (Array.isArray(pool) ? pool : []).filter(option =>
31
+ Number.isFinite(option.cost) && option.cost >= 0 && Number.isFinite(option.score)
32
+ && (!constrained || option.pricingKnown === true)))
33
+ if (options.some(pool => pool.length === 0)) return null
34
+ const suffixMinimum = Array(tasks.length + 1).fill(0)
35
+ let searchSpace = 1
36
+ let searchSpaceCapped = false
37
+ for (let index = tasks.length - 1; index >= 0; index--) {
38
+ suffixMinimum[index] = suffixMinimum[index + 1] + Math.min(...options[index].map(option => option.cost))
39
+ const product = searchSpace * options[index].length
40
+ if (product > Number.MAX_SAFE_INTEGER) searchSpaceCapped = true
41
+ searchSpace = Math.min(Number.MAX_SAFE_INTEGER, product)
42
+ }
43
+ if (constrained && suffixMinimum[0] > budget + EPSILON) return null
44
+ const exact = !searchSpaceCapped && searchSpace <= Math.max(1, exactLimit)
45
+ const width = Math.max(1, Math.floor(beamWidth) || 256)
46
+ const search = { method: exact ? 'exact' : 'beam', exact, searchSpace, searchSpaceCapped, expandedStates: 0, beamPruned: 0 }
47
+ let states = [{ choices: [], routesByTask: new Map(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
48
+ for (let index = 0; index < tasks.length; index++) {
49
+ const task = tasks[index]
50
+ const expanded = []
51
+ for (const state of states) {
52
+ for (const option of options[index]) {
53
+ const cost = state.cost + option.cost
54
+ if (constrained && cost + suffixMinimum[index + 1] > budget + EPSILON) continue
55
+ const switches = (task.dependsOn ?? []).reduce((count, dependency) => {
56
+ const route = state.routesByTask.get(dependency)
57
+ return count + (route !== undefined && route !== option.route ? 1 : 0)
58
+ }, 0)
59
+ const penalty = switches * handoffPenalty
60
+ const shortfall = Math.max(0, Number(option.qualityShortfall) || 0)
61
+ const routesByTask = new Map(state.routesByTask)
62
+ routesByTask.set(task.id, option.route)
63
+ expanded.push({ choices: [...state.choices, { ...option, handoffPenalty: penalty }], routesByTask,
64
+ score: state.score + option.score - penalty, cost, switches: state.switches + switches,
65
+ relaxedCount: state.relaxedCount + (shortfall > 0 ? 1 : 0), qualityShortfall: state.qualityShortfall + shortfall })
66
+ search.expandedStates++
67
+ }
68
+ }
69
+ if (!expanded.length) return null
70
+ expanded.sort((left, right) => compareStates(left, right, minimizeCost))
71
+ if (!exact && expanded.length > width) {
72
+ // A high-utility prefix must not crowd out every affordable completion.
73
+ // This also works at width=1; quality relaxation remains lexicographic.
74
+ states = expanded.slice(0, width)
75
+ if (constrained && index < tasks.length - 1) {
76
+ const cheapest = expanded.reduce((best, state) => compareStates(state, best, true) < 0 ? state : best)
77
+ if (!states.includes(cheapest)) states[width - 1] = cheapest
78
+ }
79
+ search.beamPruned += expanded.length - states.length
80
+ } else states = expanded
81
+ }
82
+ states.sort((left, right) => compareStates(left, right, minimizeCost))
83
+ const best = states[0]
84
+ const { routesByTask, ...result } = best
85
+ return { ...result, minimumCostLowerBound: suffixMinimum[0], search }
86
+ }