@ljwei-stak/dsh-model-router 0.21.0 → 0.22.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.
@@ -4,12 +4,16 @@
4
4
  * gentle routing adjustments from user ratings and reviews.
5
5
  */
6
6
 
7
+ import { reasoningExecutionFields, unsupportedCliReasoning } from './reasoning-execution.mjs'
8
+ import { packageSelectionFields } from './package-assignments.mjs'
9
+
7
10
  export const MAX_STORED_ANSWER = 4_000
8
11
  export const MAX_STORED_TASK = 20_000
9
12
  /** Ratings move a route's quality by at most this much (quality is 0–1). */
10
13
  export const MAX_QUALITY_BIAS = 0.04
11
14
 
12
15
  const finite = value => typeof value === 'number' && Number.isFinite(value)
16
+ const nonEmptyText = value => (typeof value === 'string' && value.length > 0 ? value : null)
13
17
  const routeKey = (provider, model) => `${String(provider ?? '')}\u0000${String(model ?? '')}`
14
18
 
15
19
  function modelIdentity(value) {
@@ -20,8 +24,48 @@ function modelIdentity(value) {
20
24
  return null
21
25
  }
22
26
 
27
+ // A stop marker belongs to one attempt, not to earlier requests in a cascade.
28
+ // Contradictory positive/invalid counters remain potentially paid evidence.
29
+ function clearlyNotStarted(attempt) {
30
+ if (!attempt || typeof attempt !== 'object' || (attempt.notStarted !== true && attempt.blocked !== true)) return false
31
+ if (Object.hasOwn(attempt, 'reportedCostUsd') && attempt.reportedCostUsd !== undefined
32
+ && attempt.reportedCostUsd !== null && attempt.reportedCostUsd !== 0) return false
33
+ return !['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some(key => {
34
+ const value = attempt.usage?.[key]
35
+ return value !== undefined && value !== null && value !== 0
36
+ })
37
+ }
38
+
39
+ /** True when some quality attempt ran, or execution cannot safely be ruled out. */
40
+ export function hasStartedQualityAttempts(result) {
41
+ if (!result || !Object.hasOwn(result, 'qualityAttempts')) return false
42
+ const attempts = result.qualityAttempts
43
+ if (!Array.isArray(attempts) || attempts.length < 1 || attempts.length > 2) return true
44
+ return attempts.some(attempt => !clearlyNotStarted(attempt))
45
+ }
46
+
23
47
  /** USD cost of one package. Prefers a CLI-reported cost, else usage × configured price. */
24
48
  export function actualCost(result, pricing) {
49
+ if (result && Object.hasOwn(result, 'qualityAttempts')) {
50
+ const attempts = result.qualityAttempts
51
+ if (!Array.isArray(attempts) || attempts.length < 1 || attempts.length > 2
52
+ || attempts.some(item => !item || typeof item !== 'object' || Object.hasOwn(item, 'qualityAttempts')
53
+ || item.channel === 'official-cli' || item.billing === 'subscription')) {
54
+ return { costUsd: null, costSource: 'quality-attempts-invalid' }
55
+ }
56
+ const costs = attempts.map(item => clearlyNotStarted(item)
57
+ ? { costUsd: 0, costSource: 'not-run' }
58
+ : actualCost(item, item.pricingSnapshot ?? null))
59
+ if (costs.some(item => item.costUsd === null)) {
60
+ const knownCostUsd = costs.filter(item => item.costUsd !== null).reduce((sum, item) => sum + item.costUsd, 0)
61
+ return finite(knownCostUsd) ? { costUsd: null, costSource: 'quality-attempts-incomplete', knownCostUsd,
62
+ unknownAttemptCount: costs.filter(item => item.costUsd === null).length }
63
+ : { costUsd: null, costSource: 'cost-invalid' }
64
+ }
65
+ const total = costs.reduce((sum, item) => sum + item.costUsd, 0)
66
+ return finite(total) && total >= 0 ? { costUsd: total, costSource: 'quality-attempts' }
67
+ : { costUsd: null, costSource: 'cost-invalid' }
68
+ }
25
69
  if (result && Object.hasOwn(result, 'reportedCostUsd') && finite(result.reportedCostUsd) && result.reportedCostUsd >= 0) {
26
70
  return { costUsd: result.reportedCostUsd, costSource: 'cli-reported' }
27
71
  }
@@ -148,7 +192,10 @@ function* spendingEntries(runs) {
148
192
  amount.subscriptionRuns = 1
149
193
  if (finite(item.referenceCostUsd) && item.referenceCostUsd >= 0) amount.subscriptionUsd = item.referenceCostUsd
150
194
  } else if (finite(item.costUsd) && item.costUsd >= 0) amount.costUsd = item.costUsd
151
- else if (item.ran === true) amount.unknown = 1
195
+ else if (item.ran === true) {
196
+ amount.unknown = 1
197
+ if (finite(item.knownCostUsd) && item.knownCostUsd >= 0) amount.costUsd = item.knownCostUsd
198
+ }
152
199
  else continue
153
200
  yield { when, amount }
154
201
  }
@@ -257,11 +304,16 @@ export function applyQualityBiases(routes, biases) {
257
304
  }
258
305
 
259
306
  function storedPackage(planned, result, pricing, finishedAt, billingFor = billingOf) {
260
- const cost = !result || result.blocked || result.notStarted
307
+ const plannedEffort = nonEmptyText(planned?.recommendedReasoningEffort) ?? nonEmptyText(planned?.plannedReasoningEffort)
308
+ const startedAttempts = hasStartedQualityAttempts(result)
309
+ const qualityAttempts = Array.isArray(result?.qualityAttempts)
310
+ ? result.qualityAttempts.slice(0, 2).map(attempt => attempt && typeof attempt === 'object' ? attempt : {}) : null
311
+ const cost = !result || ((result.blocked || result.notStarted) && !startedAttempts)
261
312
  ? { costUsd: null, costSource: 'not-run', billing: null, referenceCostUsd: null }
262
313
  : billedCost(result, pricing, billingFor(result))
263
314
  return {
264
315
  id: planned.id,
316
+ ...packageSelectionFields(result?.manualAssignment !== undefined ? { ...planned, ...result } : planned),
265
317
  name: planned.name,
266
318
  objective: String(planned.objective ?? '').slice(0, 2_000),
267
319
  ...(planned.type ? { type: String(planned.type) } : {}),
@@ -270,6 +322,24 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
270
322
  dependsOn: [...(planned.dependsOn ?? [])],
271
323
  recommendedProvider: planned.recommendedProvider,
272
324
  recommendedModel: planned.recommendedModel,
325
+ ...reasoningExecutionFields(result),
326
+ ...(qualityAttempts ? { qualityAttempts: qualityAttempts.map(attempt => ({
327
+ provider: attempt.provider, model: attempt.model, channel: 'harness-llm',
328
+ ok: attempt.ok === true, usage: attempt.usage ?? null,
329
+ ...(attempt.notStarted === true ? { notStarted: true } : {}),
330
+ ...(attempt.blocked === true ? { blocked: true } : {}),
331
+ ...(attempt.usageIncomplete ? { usageIncomplete: true } : {}),
332
+ ...(finite(attempt.reportedCostUsd) && attempt.reportedCostUsd >= 0 ? { reportedCostUsd: attempt.reportedCostUsd } : {}),
333
+ pricingSnapshot: attempt.pricingSnapshot ? { ...attempt.pricingSnapshot } : null,
334
+ ...reasoningExecutionFields(attempt),
335
+ ...(finite(attempt.durationMs) ? { durationMs: attempt.durationMs } : {}),
336
+ ...(finite(attempt.inputChars) ? { inputChars: attempt.inputChars } : {}),
337
+ finishedAt: attempt.finishedAt, error: typeof attempt.error === 'string' ? attempt.error.slice(0, 1_000) : null,
338
+ })) } : {}),
339
+ ...(result?.qualityValidation ? { qualityValidation: { ...result.qualityValidation } } : {}),
340
+ ...(plannedEffort ? { plannedReasoningEffort: plannedEffort } : {}),
341
+ ...(finite(result?.durationMs) && result.durationMs >= 0 ? { durationMs: result.durationMs } : {}),
342
+ ...(finite(result?.inputChars) && result.inputChars >= 0 ? { inputChars: result.inputChars } : {}),
273
343
  difficulty: planned.difficulty ?? null,
274
344
  estimatedCost: finite(planned.estimatedCost) ? planned.estimatedCost : null,
275
345
  plannedChannel: planned.executionChannel ?? null,
@@ -278,7 +348,7 @@ function storedPackage(planned, result, pricing, finishedAt, billingFor = billin
278
348
  status: !result ? 'pending' : result.ok ? (result.fallback ? 'fallback' : 'succeeded') : result.paused ? 'paused'
279
349
  : result.waiting ? 'waiting' : result.blocked ? 'blocked' : result.cancelled ? 'cancelled' : 'failed',
280
350
  ok: result?.ok === true,
281
- ran: Boolean(result) && !result.blocked && !result.notStarted,
351
+ ran: Boolean(result) && (startedAttempts || (!result.blocked && !result.notStarted)),
282
352
  blocked: result?.blocked === true,
283
353
  ...(result?.paused ? { paused: true, pause: { ...result.pause } } : {}),
284
354
  ...(result?.waiting ? { waiting: true } : {}),
@@ -324,6 +394,7 @@ export function plannedPackages(plan, task) {
324
394
  const route = plan.directRoute ?? plan.selected
325
395
  return [{ id: 'direct', name: '指定模型', objective: String(task ?? ''), dependsOn: [],
326
396
  recommendedProvider: route?.provider, recommendedModel: route?.model,
397
+ ...(typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? { recommendedReasoningEffort: route.reasoningEffort } : {}),
327
398
  difficulty: plan.complexity?.band ?? null, estimatedCost: plan.estimatedCost ?? null,
328
399
  executionChannel: plan.executionChannel ?? null }]
329
400
  }
@@ -344,6 +415,9 @@ export function buildRunRecord({ id, createdAt, task, plan, execution, preset =
344
415
  executionMode,
345
416
  routingBypassed: plan?.routingBypassed === true,
346
417
  mode: plan?.mode ?? 'single',
418
+ ...(typeof plan?.assignmentMode === 'string' && plan.assignmentMode.trim().length > 0
419
+ && plan.assignmentMode.length <= 40 && !/[\u0000-\u001f\u007f]/u.test(plan.assignmentMode)
420
+ ? { assignmentMode: plan.assignmentMode.trim() } : {}),
347
421
  preset,
348
422
  decision: {
349
423
  dataVersions: plan?.optimization?.dataVersions ?? null,
@@ -385,6 +459,10 @@ export function storedResults(run) {
385
459
  id: item.id, name: item.name, ok: item.ok, provider: item.provider, model: item.model,
386
460
  channel: item.channel, answer: item.answer, error: item.error, fallback: item.fallback,
387
461
  blocked: item.blocked, finishedAt: item.finishedAt,
462
+ ...packageSelectionFields(item),
463
+ ...reasoningExecutionFields(item),
464
+ ...(finite(item.durationMs) && item.durationMs >= 0 ? { durationMs: item.durationMs } : {}),
465
+ ...(finite(item.inputChars) && item.inputChars >= 0 ? { inputChars: item.inputChars } : {}),
388
466
  ...(item.paused ? { paused: true } : {}), ...(item.waiting ? { waiting: true } : {}),
389
467
  }))
390
468
  }
@@ -406,6 +484,10 @@ export function teamExecutionResults(plan, execution) {
406
484
  cancelled: result.status === 'cancelled',
407
485
  provider: result.provider,
408
486
  model: result.recommendedModel,
487
+ ...unsupportedCliReasoning({ reasoningEffort: result.recommendedReasoningEffort }),
488
+ ...reasoningExecutionFields(result),
489
+ ...(finite(result.durationMs) && result.durationMs >= 0 ? { durationMs: result.durationMs } : {}),
490
+ ...(finite(result.inputChars) && result.inputChars >= 0 ? { inputChars: result.inputChars } : {}),
409
491
  requestedCliModel: result.requestedCliModel ?? null,
410
492
  actualModel: result.actualModel ?? null,
411
493
  ...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
@@ -21,6 +21,8 @@ import { installedModuleRoots } from './vendor-mimo-grok-adapter.mjs'
21
21
  import { CODE_FILE, npmAttestor } from './npm-attestation.mjs'
22
22
  import { normalizeExecutionPreference } from './model-profiles.mjs'
23
23
  import { BILLING_MODE_LABEL, billingPlan, detectQuotaExhaustion, vendorKey } from './subscription-billing.mjs'
24
+ import { reasoningExecutionFields, reasoningRouteForAssignment, routeForReasoningSwitch, unsupportedCliReasoning } from './reasoning-execution.mjs'
25
+ import { parsePackageAssignments, packageSelectionFields } from './package-assignments.mjs'
24
26
 
25
27
  export const MAX_TASK_BYTES = 64_000
26
28
  const MAX_ANSWER_CHARS = 48_000
@@ -42,6 +44,17 @@ const BASE_ENV_KEYS = Object.freeze([
42
44
 
43
45
  const READ_ONLY_INSTRUCTION = 'Read the task supplied on standard input. Answer in the task language. Do not modify files.'
44
46
 
47
+ function additionalApiFields(value) {
48
+ if (!value || typeof value !== 'object') return {}
49
+ return {
50
+ ...(value.notStarted === true ? { notStarted: true } : {}),
51
+ ...(value.usageIncomplete === true ? { usageIncomplete: true } : {}),
52
+ ...(value.qualityValidation ? { qualityValidation: value.qualityValidation } : {}),
53
+ ...(Array.isArray(value.qualityAttempts) ? { qualityAttempts: value.qualityAttempts,
54
+ ...(typeof value.provider === 'string' && typeof value.model === 'string' ? { provider: value.provider, model: value.model } : {}) } : {}),
55
+ }
56
+ }
57
+
45
58
  /** Portable headless adapters. Registry tools without an entry here use the verified runner. */
46
59
  export const TASK_ADAPTERS = Object.freeze([
47
60
  Object.freeze({
@@ -167,8 +180,13 @@ const TRUNCATED = '…(已截断)'
167
180
 
168
181
  function truncatedTo(value, maxBytes) {
169
182
  const textValue = String(value ?? '')
170
- if (Buffer.byteLength(textValue, 'utf8') <= maxBytes) return textValue
171
- return `${sliceUtf8(textValue, Math.max(0, maxBytes - Buffer.byteLength(TRUNCATED, 'utf8')))}${TRUNCATED}`
183
+ const limit = Math.max(0, Math.floor(maxBytes) || 0)
184
+ if (Buffer.byteLength(textValue, 'utf8') <= limit) return textValue
185
+ const markerBytes = Buffer.byteLength(TRUNCATED, 'utf8')
186
+ // A marker must not consume more than the whole allocation. Very small
187
+ // shares keep useful content rather than an oversized or partial marker.
188
+ if (limit < markerBytes) return sliceUtf8(textValue, limit)
189
+ return `${sliceUtf8(textValue, limit - markerBytes)}${TRUNCATED}`
172
190
  }
173
191
 
174
192
  function checkedModel(modelId) {
@@ -626,6 +644,8 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
626
644
  ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter?.id ?? null, answer: '',
627
645
  fallback: null, timedOut, exitCode, paused: true,
628
646
  pause: { ...api.pause, ...(detail ? { detail } : {}), ...(fallback.loginRequired ? { loginRequired: true } : {}) },
647
+ ...reasoningExecutionFields(api),
648
+ ...additionalApiFields(api),
629
649
  error: api.error,
630
650
  }
631
651
  }
@@ -635,6 +655,8 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
635
655
  ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
636
656
  answer: '', fallback, timedOut, exitCode, error: '执行已取消。', cancelled: true,
637
657
  ...(api?.usage ? { usage: api.usage } : {}),
658
+ ...reasoningExecutionFields(api),
659
+ ...additionalApiFields(api),
638
660
  }
639
661
  }
640
662
  // A billing-aware fallback can refuse (subscription-only) or explain a switch.
@@ -644,6 +666,9 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
644
666
  provider, model, preference, channel: api?.refused ? 'official-cli' : 'harness-llm', toolId: adapter?.id ?? null,
645
667
  answer, fallback: note?.reason ? { ...fallback, reason: note.reason } : api?.refused ? { ...fallback, reason: api.error } : fallback, timedOut, exitCode,
646
668
  ...(api?.usage ? { usage: api.usage } : {}),
669
+ ...reasoningExecutionFields(api),
670
+ ...additionalApiFields(api),
671
+ ...(api?.notStarted === true ? { notStarted: true } : {}),
647
672
  ...(note ? { billingSwitch: note } : {}),
648
673
  ...(api?.billing ? { billing: api.billing } : {}),
649
674
  error: api?.ok === true && answer.trim() ? undefined : (api?.error || reason),
@@ -676,6 +701,7 @@ function officialSuccess({ route, adapter, preference, answer, exitCode, version
676
701
  requestedCliModel = null, reportedModel = null, modelNotice = null, source, sandboxEnforcement }) {
677
702
  const actualModel = reportedCliModel(reportedModel)
678
703
  return {
704
+ ...unsupportedCliReasoning(route),
679
705
  ok: true,
680
706
  provider: route.provider,
681
707
  model: route.model,
@@ -952,10 +978,15 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
952
978
  if (!apiRoute || (sub?.kind === 'plan-key' && apiRoute.provider === sub.route.provider && apiRoute.model === sub.route.model)) {
953
979
  return refuse(`${reason.replace(/,?已切换 API Key。$/u, '。')}没有配置可回退的 API 路线(apiRoute)。`, note ? { billingSwitch: { ...note, to: null } } : {})
954
980
  }
955
- const result = await useApiRoute(apiFallback, { route: apiRoute, task: options.task, signal: options.signal, reason, skipped })
981
+ const reasoningRoute = routeForReasoningSwitch(route, apiRoute)
982
+ const originalEffort = typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? route.reasoningEffort : null
983
+ const result = await useApiRoute(apiFallback, { route: reasoningRoute, task: options.task, signal: options.signal, reason, skipped })
984
+ const droppedEffort = originalEffort && reasoningRoute.reasoningEffort !== originalEffort ? originalEffort : null
956
985
  // Keep the API route that actually ran (pricing); the plan route is recorded separately.
957
986
  return {
958
- ...base, ...result, billingMode: plan.mode, billing: 'api',
987
+ ...base, ...result,
988
+ ...(droppedEffort ? { plannedReasoningEffort: droppedEffort, reasoningEffortStatus: 'unapplied' } : {}),
989
+ billingMode: plan.mode, billing: 'api',
959
990
  ...(note ? { billingSwitch: { ...note, to: 'api' } } : {}),
960
991
  ...(sub?.kind === 'plan-key' ? { subscriptionRoute: { ...sub.route } } : {}),
961
992
  }
@@ -985,13 +1016,19 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
985
1016
  }
986
1017
 
987
1018
  if (sub.kind === 'plan-key') {
1019
+ const reasoningRoute = routeForReasoningSwitch(route, sub.route)
1020
+ const originalEffort = typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? route.reasoningEffort : null
988
1021
  let attempt
989
- try { attempt = await apiFallback({ route: sub.route, task: options.task, signal: options.signal, reason: '编程套餐(订阅)', subscription: true }) }
1022
+ try { attempt = await apiFallback({ route: reasoningRoute, task: options.task, signal: options.signal, reason: '编程套餐(订阅)', subscription: true }) }
990
1023
  catch (error) { attempt = { ok: false, error: String(error?.message ?? error) } }
1024
+ const droppedEffort = originalEffort && reasoningRoute.reasoningEffort !== originalEffort ? originalEffort : null
991
1025
  if (attempt?.ok === true && String(attempt.answer ?? '').trim()) {
992
1026
  return {
993
1027
  ok: true, ...base, preference: executionPreference(route), channel: 'harness-llm', toolId: null,
994
1028
  answer: String(attempt.answer).slice(0, MAX_ANSWER_CHARS), fallback: null,
1029
+ ...reasoningExecutionFields(attempt),
1030
+ ...additionalApiFields(attempt),
1031
+ ...(droppedEffort ? { plannedReasoningEffort: droppedEffort, reasoningEffortStatus: 'unapplied' } : {}),
995
1032
  billing: 'subscription', subscriptionRoute: { ...sub.route },
996
1033
  ...(attempt.usage ? { usage: attempt.usage } : {}),
997
1034
  }
@@ -1058,8 +1095,13 @@ ${args?.raw ?? ''}`, vendor)
1058
1095
  if (plan.mode === 'subscription-only') {
1059
1096
  return { ok: false, refused: true, error: reason.replace('已切换 API Key。', '按设置只用订阅,未使用 API Key。'), billingNote: { ...note, to: null } }
1060
1097
  }
1061
- const api = await apiFallback({ ...args, route: apiRoute ?? args.route })
1062
- return { ...api, billing: 'api', billingNote: { ...note, to: 'api' } }
1098
+ const reasoningRoute = routeForReasoningSwitch(args.route, apiRoute ?? args.route)
1099
+ const api = await apiFallback({ ...args, route: reasoningRoute })
1100
+ const droppedEffort = typeof args.route?.reasoningEffort === 'string' && args.route.reasoningEffort
1101
+ && reasoningRoute.reasoningEffort !== args.route.reasoningEffort ? args.route.reasoningEffort : null
1102
+ return { ...api,
1103
+ ...(droppedEffort ? { plannedReasoningEffort: droppedEffort, reasoningEffortStatus: 'unapplied' } : {}),
1104
+ billing: 'api', billingNote: { ...note, to: 'api' } }
1063
1105
  }
1064
1106
  if (plan.mode === 'subscription-only') return { ok: false, refused: true, error: `${args?.reason ?? '官方 CLI 执行失败。'}(按设置只用订阅,未回退 API)` }
1065
1107
  // The subscription really ran and failed for another reason: do not spend API money silently.
@@ -1100,7 +1142,7 @@ const DEPENDENCY_ANSWER_BYTES = 7_500
1100
1142
  * The prompt for one work package, always within MAX_TASK_BYTES: the objective
1101
1143
  * is capped first, dependency answers share what the task leaves (each at most
1102
1144
  * DEPENDENCY_ANSWER_BYTES, the remainder split evenly), and the total task is
1103
- * truncated last. Truncated parts end with “(已截断)”.
1145
+ * truncated last. Truncated parts include a marker when their share permits it.
1104
1146
  */
1105
1147
  export function packagePrompt(task, item, completed, maxBytes = MAX_TASK_BYTES) {
1106
1148
  const dependencies = (item.dependsOn ?? []).map(id => completed.find(result => result.id === id)).filter(Boolean)
@@ -1114,13 +1156,17 @@ export function packagePrompt(task, item, completed, maxBytes = MAX_TASK_BYTES)
1114
1156
  const taskBytes = bytes(String(task))
1115
1157
  const free = Math.max(0, budget - fixed)
1116
1158
  // Dependencies get what they need up to their cap, but never squeeze the task below its share.
1117
- const wantDeps = dependencies.reduce((sum, dep) => sum + Math.min(bytes(String(dep.answer ?? '')), DEPENDENCY_ANSWER_BYTES), 0)
1159
+ const wanted = dependencies.map(dep => Math.min(bytes(String(dep.answer ?? '')), DEPENDENCY_ANSWER_BYTES))
1160
+ const wantDeps = wanted.reduce((sum, value) => sum + value, 0)
1118
1161
  const depBudget = Math.max(0, Math.min(wantDeps, free - Math.min(taskBytes, MIN_TASK_SHARE_BYTES)))
1119
1162
  let remaining = depBudget
1163
+ let remainingWanted = wantDeps
1120
1164
  const depTexts = dependencies.map((dep, index) => {
1121
- const share = Math.floor(remaining / (dependencies.length - index))
1122
- const answer = truncatedTo(String(dep.answer ?? ''), Math.min(DEPENDENCY_ANSWER_BYTES, share))
1165
+ const share = remaining >= remainingWanted ? wanted[index]
1166
+ : Math.min(wanted[index], Math.floor(remaining / (dependencies.length - index)))
1167
+ const answer = truncatedTo(String(dep.answer ?? ''), share)
1123
1168
  remaining -= bytes(answer)
1169
+ remainingWanted -= wanted[index]
1124
1170
  return `${truncatedTo(dep.name, 200)}:\n${answer}`
1125
1171
  })
1126
1172
  const taskText = truncatedTo(String(task), Math.max(0, free - (depBudget - remaining)))
@@ -1146,6 +1192,7 @@ export function assignmentPackages(plan, task) {
1146
1192
  dependsOn: [],
1147
1193
  recommendedProvider: route.provider,
1148
1194
  recommendedModel: route.model,
1195
+ ...(typeof route.reasoningEffort === 'string' && route.reasoningEffort ? { recommendedReasoningEffort: route.reasoningEffort } : {}),
1149
1196
  }]
1150
1197
  }
1151
1198
  const packages = plan?.team?.workPackages
@@ -1157,6 +1204,10 @@ export function assignmentPackages(plan, task) {
1157
1204
  dependsOn: [...(item.dependsOn ?? [])],
1158
1205
  recommendedProvider: item.recommendedProvider,
1159
1206
  recommendedModel: item.recommendedModel,
1207
+ ...(item.type ? { type: item.type } : {}),
1208
+ ...(item.purpose ? { purpose: item.purpose } : {}),
1209
+ ...packageSelectionFields(item),
1210
+ ...(typeof item.recommendedReasoningEffort === 'string' && item.recommendedReasoningEffort ? { recommendedReasoningEffort: item.recommendedReasoningEffort } : {}),
1160
1211
  }))
1161
1212
  }
1162
1213
 
@@ -1172,6 +1223,75 @@ function aggregateOf(results) {
1172
1223
  return { status, packages: results, aggregate }
1173
1224
  }
1174
1225
 
1226
+ function checkedPackageSelections(packages, routes, overrides, runVerified, targets) {
1227
+ const preceding = new Set()
1228
+ const selected = {}
1229
+ const effective = packages.map(item => {
1230
+ if (typeof item?.id !== 'string' || item.id !== item.id.trim() || !item.id || item.id.length > 128
1231
+ || /[\u0000-\u001f\u007f]/u.test(item.id) || ['__proto__', 'constructor', 'prototype'].includes(item.id)
1232
+ || preceding.has(item.id) || !Array.isArray(item.dependsOn ?? [])
1233
+ || (item.dependsOn ?? []).some(id => !preceding.has(id))) {
1234
+ throw new TypeError('Work packages require unique ids and topological dependencies')
1235
+ }
1236
+ preceding.add(item.id)
1237
+ const override = Object.hasOwn(overrides, item.id) ? overrides[item.id] : null
1238
+ const next = item.manualAssignment && override?.provider && override?.model
1239
+ ? { ...item, manualAssignment: { kind: 'api', provider: override.provider, model: override.model } } : item
1240
+ if (next.manualAssignment !== undefined) selected[next.id] = next.manualAssignment
1241
+ return next
1242
+ })
1243
+ const normalized = Object.keys(selected).length ? parsePackageAssignments(selected) : undefined
1244
+ for (const [id, target] of Object.entries(normalized ?? {})) {
1245
+ // Retained successful receipts are context, not new execution requests.
1246
+ // Their exact intent still validates above; a removed catalog entry or
1247
+ // unavailable runner must not prevent a different target from retrying.
1248
+ if (targets && !targets.has(id)) continue
1249
+ if (target.kind === 'api' && !routes.some(route => route.provider === target.provider && route.model === target.model)) {
1250
+ throw new TypeError(`Manual API route is not configured for work package ${id}`)
1251
+ }
1252
+ if (target.kind === 'tool' && typeof runVerified !== 'function') {
1253
+ throw new TypeError('Manual official tool assignments require runVerified')
1254
+ }
1255
+ }
1256
+ return effective.map(item => normalized?.[item.id]
1257
+ ? { ...item, ...packageSelectionFields({ ...item, manualAssignment: normalized[item.id] }) } : item)
1258
+ }
1259
+
1260
+ async function runManualTool(target, prompt, options) {
1261
+ const base = { provider: 'official-tool', model: target.toolId, toolId: target.toolId,
1262
+ channel: 'official-cli', preference: 'official', requestedCliModel: null,
1263
+ modelNotice: '使用所选官方工具配置的默认模型。', fallback: null }
1264
+ try {
1265
+ const raw = await options.runVerified({ toolId: target.toolId, task: prompt, workspace: options.workspace,
1266
+ mode: 'read-only', signal: options.signal })
1267
+ if (!raw || typeof raw.status !== 'string') throw new TypeError('官方工具未返回有效执行状态。')
1268
+ const answer = typeof raw.finalText === 'string' ? raw.finalText : ''
1269
+ const ok = raw.status === 'succeeded' && answer.trim().length > 0
1270
+ const executionEvidence = Number.isFinite(raw.startedAt) || raw.usage != null
1271
+ || (Number.isFinite(raw.reportedCostUsd) && raw.reportedCostUsd > 0)
1272
+ const notStarted = !executionEvidence && (raw.status === 'unsupported' || raw.notStarted === true || raw.blocked === true)
1273
+ return { ...base, ok, answer: answer.slice(0, MAX_ANSWER_CHARS),
1274
+ actualModel: reportedCliModel(raw.actualModel ?? raw.reportedModel),
1275
+ ...(raw.reportedModel !== undefined ? { reportedModel: raw.reportedModel } : {}),
1276
+ ...(raw.usage != null ? { usage: raw.usage } : {}),
1277
+ ...(raw.usageIncomplete === true ? { usageIncomplete: true } : {}),
1278
+ ...(Number.isFinite(raw.reportedCostUsd) && raw.reportedCostUsd >= 0 ? { reportedCostUsd: raw.reportedCostUsd } : {}),
1279
+ ...Object.fromEntries(['credentialSource', 'source', 'sandboxEnforcement', 'executionEnvironment']
1280
+ .filter(key => typeof raw[key] === 'string').map(key => [key, raw[key]])),
1281
+ ...(Number.isInteger(raw.exitCode) ? { exitCode: raw.exitCode } : {}),
1282
+ ...(notStarted ? { notStarted: true } : {}),
1283
+ ...(raw.status === 'cancelled' || options.signal?.aborted ? { cancelled: true } : {}),
1284
+ ...(raw.status === 'timed-out' ? { timedOut: true } : {}),
1285
+ ...(raw.status === 'output-limit' || answer.length > MAX_ANSWER_CHARS ? { truncated: true } : {}),
1286
+ ...(!ok && (raw.diagnostic || raw.outputTail) ? { diagnostic: redactDiagnostic(raw.diagnostic ?? raw.outputTail) } : {}),
1287
+ error: ok ? undefined : redactDiagnostic(raw.error ?? raw.reason ?? (answer.trim() ? '官方工具执行失败。' : '官方工具未返回有效文本。')) }
1288
+ } catch (error) {
1289
+ return { ...base, ok: false, answer: '', actualModel: null, usageIncomplete: true,
1290
+ ...(options.signal?.aborted || error?.name === 'AbortError' ? { cancelled: true } : {}),
1291
+ error: redactDiagnostic(error?.message ?? error) }
1292
+ }
1293
+ }
1294
+
1175
1295
  /**
1176
1296
  * Run `targets` (all packages when null) in plan order. Packages outside the
1177
1297
  * target set keep their previous results, so a single failed step can be
@@ -1180,20 +1300,26 @@ function aggregateOf(results) {
1180
1300
  async function runPackages({ packages, task, routingBypassed, routes, previous = [], targets = null, overrides = {}, choices = {}, options }) {
1181
1301
  const routeList = Array.isArray(routes) ? routes : []
1182
1302
  const { credentialsFor, onPackage, billing, ...runOptions } = options
1303
+ const effectivePackages = checkedPackageSelections(packages, routeList, overrides, runOptions.runVerified, targets)
1183
1304
  const results = []
1184
1305
  const ranIds = []
1185
- for (const item of packages) {
1306
+ for (const item of effectivePackages) {
1186
1307
  const kept = previous.find(result => result.id === item.id)
1187
1308
  if (targets && !targets.has(item.id) && kept) {
1188
1309
  results.push(kept)
1189
1310
  continue
1190
1311
  }
1191
- const override = overrides[item.id]
1192
- const provider = override?.provider ?? item.recommendedProvider
1193
- const model = override?.model ?? item.recommendedModel
1312
+ const override = Object.hasOwn(overrides, item.id) ? overrides[item.id] : undefined
1313
+ const selection = packageSelectionFields(item)
1314
+ const target = selection.manualAssignment
1315
+ const provider = target?.kind === 'tool' ? 'official-tool' : target?.provider ?? override?.provider ?? item.recommendedProvider
1316
+ const model = target?.kind === 'tool' ? target.toolId : target?.model ?? override?.model ?? item.recommendedModel
1317
+ const identity = { ...selection, ...(item.type ? { type: item.type } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) }
1194
1318
  if (runOptions.signal?.aborted) {
1195
1319
  // Cancelled: later packages are not started (no CLI process, no API spend).
1196
- const cancelled = { id: item.id, name: item.name, ok: false, provider, model, channel: 'harness-llm', answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
1320
+ const cancelled = { id: item.id, name: item.name, ok: false, provider, model,
1321
+ channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm', ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}),
1322
+ ...identity, answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
1197
1323
  results.push(cancelled)
1198
1324
  ranIds.push(item.id)
1199
1325
  if (typeof onPackage === 'function') await onPackage(cancelled)
@@ -1205,7 +1331,8 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1205
1331
  const waitingOn = unmet.filter(id => { const dep = results.find(result => result.id === id); return dep?.paused || dep?.waiting })
1206
1332
  const blocked = {
1207
1333
  id: item.id, name: item.name, ok: false, provider, model,
1208
- channel: 'harness-llm', answer: '', fallback: null, blocked: true,
1334
+ channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm', ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}),
1335
+ ...identity, answer: '', fallback: null, blocked: true,
1209
1336
  ...(waitingOn.length ? { waiting: true } : {}),
1210
1337
  error: waitingOn.length ? `等待上游步骤确认:${waitingOn.join('、')}` : `依赖未完成:${unmet.join('、')}`,
1211
1338
  }
@@ -1214,17 +1341,45 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1214
1341
  if (typeof onPackage === 'function') await onPackage(blocked)
1215
1342
  continue
1216
1343
  }
1217
- const route = routeList.find(candidate => candidate.provider === provider && candidate.model === model)
1344
+ const plannedRoute = routeList.find(candidate => candidate.provider === provider && candidate.model === model)
1218
1345
  ?? { provider, model }
1219
- const credentials = typeof credentialsFor === 'function' ? await credentialsFor(route) : runOptions.credentials ?? null
1220
- const result = await executeRouteWithBilling({
1221
- ...runOptions,
1222
- billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
1223
- credentials,
1224
- route,
1225
- task: routingBypassed ? checkedTask(task) : packagePrompt(task, item, results),
1226
- })
1227
- const entry = { id: item.id, name: item.name, ...(override ? { reassigned: true } : {}), ...result }
1346
+ const route = target?.kind === 'api' ? { ...plannedRoute, execution: 'api', manualPinned: true }
1347
+ : target?.kind === 'tool' ? { provider, model } : reasoningRouteForAssignment(plannedRoute, item, override)
1348
+ if (target?.kind === 'api') delete route.reasoningEffort
1349
+ const promptText = routingBypassed ? checkedTask(task) : packagePrompt(task, item, results)
1350
+ const startedAt = Date.now()
1351
+ let result
1352
+ if (target?.kind === 'tool') result = await runManualTool(target, promptText, runOptions)
1353
+ else if (target?.kind === 'api') result = { ...await useApi(runOptions.apiFallback, { route, task: promptText,
1354
+ signal: runOptions.signal, preference: 'api', reason: '按工作包手动选择模型目录 API。' }), fallback: null }
1355
+ else {
1356
+ const credentials = typeof credentialsFor === 'function' ? await credentialsFor(route) : runOptions.credentials ?? null
1357
+ result = await executeRouteWithBilling({
1358
+ ...runOptions,
1359
+ billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
1360
+ credentials, route, task: promptText,
1361
+ })
1362
+ }
1363
+ const durationMs = Date.now() - startedAt
1364
+ const plannedEffort = typeof item.recommendedReasoningEffort === 'string' && item.recommendedReasoningEffort
1365
+ ? item.recommendedReasoningEffort
1366
+ : null
1367
+ const effortFields = {
1368
+ ...reasoningExecutionFields(result),
1369
+ ...(plannedEffort && route.reasoningEffort !== plannedEffort
1370
+ ? { plannedReasoningEffort: plannedEffort, reasoningEffortStatus: target?.kind === 'tool' ? 'unsupported' : 'unapplied' }
1371
+ : {}),
1372
+ }
1373
+ const entry = {
1374
+ id: item.id,
1375
+ name: item.name,
1376
+ ...(override ? { reassigned: true } : {}),
1377
+ ...result,
1378
+ ...identity,
1379
+ ...effortFields,
1380
+ ...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
1381
+ inputChars: promptText.length,
1382
+ }
1228
1383
  results.push(entry)
1229
1384
  ranIds.push(item.id)
1230
1385
  if (typeof onPackage === 'function') await onPackage(entry)
@@ -166,6 +166,10 @@ export function parseInstallPreferences(config = {}, { tools = [], home = '', en
166
166
 
167
167
  /** Where a script install puts the binary: the user's directory, else the vendor default. */
168
168
  export function scriptInstallDir(tool, preferences, { home = '', env = {}, method } = {}) {
169
+ // Only a chosen root overrides Hermes' source location. Its user home and
170
+ // default global command directory remain owned by the vendor installer.
171
+ if (tool?.id === 'hermes') return preferences?.installDir
172
+ ? joinPath(preferences.installDir, method?.installDirSubdir || 'hermes-agent') : ''
169
173
  const binaryDir = vendorScriptDir(tool, { home, env, method })
170
174
  if (!binaryDir) return ''
171
175
  if (preferences?.installDir) return method?.installDirSubdir ? joinPath(preferences.installDir, method.installDirSubdir) : preferences.installDir
@@ -285,6 +289,9 @@ export function resolveInstallPlan(tool, methodId, preferences = {}, { platform
285
289
  : target ? [] : installDir
286
290
  ? [`${method.label} 无法使用自定义安装目录(官方脚本写死了安装路径),将安装到 ${vendorScriptDir(tool, { home, env, method })}。`]
287
291
  : []
292
+ if (tool.id === 'hermes' && target) {
293
+ notices.push(`Hermes 源码安装目录:${target};插件启动器目录:${binDir}。配置、登录和历史保留原位置,不迁移现有数据;官方安装器可能更新全局命令使其指向所选源码。`)
294
+ }
288
295
  if (method.registryEnv === 'MCODE_NPM_REGISTRY' && preferences.registry && !minimaxScriptRegistry(preferences.registry)) {
289
296
  notices.push('MiniMax 官方脚本只接受 https://registry.npmjs.org/ 或 https://registry.npmmirror.com/,本次不改写它的 npm 源。')
290
297
  }
@@ -360,10 +367,11 @@ export function resolveInstallLocation(tool, method, preferences = {}, { home =
360
367
  if (method.kind === 'script') {
361
368
  // A method that cannot honour the configured directory always lands in the
362
369
  // vendor's own location, whatever the user typed.
363
- const dir = methodHonoursInstallDir(method)
370
+ const target = methodHonoursInstallDir(method)
364
371
  ? scriptInstallDir(tool, preferences, { home, env, method })
365
- : vendorScriptDir(tool, { home, env, method })
366
- const binDir = methodHonoursInstallDir(method) && method.installDirSuffix ? joinPath(dir, method.installDirSuffix) : dir
372
+ : ''
373
+ const dir = target || vendorScriptDir(tool, { home, env, method })
374
+ const binDir = target && method.installDirSuffix ? joinPath(target, method.installDirSuffix) : dir
367
375
  return { kind: 'script', dir: binDir, installDir: dir, binaries: [...(method.scriptBinaryNames ?? tool.scriptBinaryNames ?? [])] }
368
376
  }
369
377
  return { kind: 'package-manager', dir: packageManagerBinDir(preferences?.installDir || '', { platform }), installDir: preferences?.installDir || '', binaries: [] }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0 — 2026-10-19
4
+
5
+ - Add an explicit algorithm/manual assignment choice after task decomposition. Manually select an exact model API or registered official tool for every package, retain the original recommendation and dependencies, restore recommendations, and invalidate stale execution previews after edits.
6
+ - Validate the entire assignment before the first paid request, recompute per-package API estimates, check tool readiness, and execute the prepared plan. Manual APIs use adapter-default effort and stay pinned through budget checks and JSON validation; manual tools use their own default model and never silently fall back to API. Mixed execution is read-only; editable official teams retain their existing isolated-worktree lifecycle.
7
+ - Persist manual intent and original recommendations with actual channels, model evidence and costs. Retries retain selected targets, preserve completed upstream results even when their model leaves the catalog, and revalidate image capabilities after reassignment.
8
+ - Import explicit objective training evidence for task-related quality estimates and compare model/effort actions only with enough matching evidence. Learn empirical P90 token and duration resources from complete successful records, retaining conservative cold-start and missing-price fallbacks.
9
+ - Improve bounded DAG assignment search using future dependency boundaries, safe prefix dominance and optimistic handoff ranking. Beam search remains approximate, and synthetic/oracle tests do not establish production answer-quality or cost gains.
10
+ - Carry exact supported API effort IDs into execution, distinguish requested controls from unverified backend effort, and prevent cross-model effort leakage. Optional JSON-structure validation and one API upgrade default to off and respect subscription and budget restrictions.
11
+ - Retain earlier paid attempts when an upgrade fails or never starts; preserve unknown spending separately. Fix short dependency answers being replaced by truncation markers while keeping the total UTF-8 prompt limit.
12
+
13
+ Validation: 966 automated tests, 963 passed and 3 platform-dependent skips; 12 real Chromium interactions against offline UI fixtures passed. Frozen pnpm installation, client build/check and package verification passed. No new real provider or vendor CLI model calls were made. An existing Windows multi-process lock test had one transient EPERM during development; isolated rechecks and the final full suite passed, without a lock implementation change. See [release acceptance and limits](docs/releases/0.22.0-validation.md).
14
+
15
+ Fully quit Harness, including its tray and Host background processes, update the plugin to 0.22.0, and restart.
16
+
17
+ ## 0.21.1 — 2026-10-18
18
+
19
+ - Honor Hermes custom program directories on Windows and POSIX through the official `-InstallDir` / `--dir` parameter. Place the checkout in the shared root's `hermes-agent` child and bind probes and terminals to its source-local `.hermes/bin` launcher; an old global command cannot satisfy a missing selected installation.
20
+ - Retain existing Hermes configuration, login and history locations. Keep vendor-default discovery when the shared root is empty, preserve old source trees and explain the separate program/data locations. Installation does not migrate user data; the official installer may refresh its global convenience command.
21
+ - Move the official-tools interactive terminal above onboarding and tool maintenance, preserving the existing component and tab/session lifecycle.
22
+ - Add offline directory, installer-argument and selected-entry regressions. Vendor installation and model calls are not part of this release's acceptance.
23
+
3
24
  ## 0.21.0 — 2026-10-18
4
25
 
5
26
  - Added support for Hermes Agent (NousResearch source installer): includes native Windows PowerShell and POSIX Bash non-interactive flags, vendor-specific launcher and data paths, `hermes auth` authentication flow, and manual uninstall via `hermes uninstall`.
package/README.i18n.yaml CHANGED
@@ -1,4 +1,4 @@
1
1
  # README translation pairing for this repository.
2
2
  # Reviewed together; Git blob hashes identify the exact synchronized texts.
3
- README.md: 04f64547cf8a120e07e15e9f649ed7001e6d23c5
4
- README.zh.md: 5bd65b5afeb80afcc047dbde68724861746f2975
3
+ README.md: b7789d744cd5c467bc577c91d344e1b85a2a375e
4
+ README.zh.md: 6605958c924e9fddb96ed7aee1ceef070a0f7c22