@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.
- package/.dsh-plugin/client.js +889 -91
- package/.dsh-plugin/index.mjs +225 -25
- package/.dsh-plugin/shared/assignment-solver.mjs +235 -35
- package/.dsh-plugin/shared/harness-plan.mjs +16 -4
- package/.dsh-plugin/shared/manual-plan.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +8 -1
- package/.dsh-plugin/shared/official-tool-registry.mjs +5 -3
- package/.dsh-plugin/shared/official-tools-remote.mjs +10 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +1 -0
- package/.dsh-plugin/shared/package-assignments.mjs +71 -0
- package/.dsh-plugin/shared/quality-gates.mjs +169 -0
- package/.dsh-plugin/shared/reasoning-execution.mjs +92 -0
- package/.dsh-plugin/shared/router-state.mjs +2 -1
- package/.dsh-plugin/shared/router.mjs +124 -12
- package/.dsh-plugin/shared/routing-evidence.mjs +720 -0
- package/.dsh-plugin/shared/run-ledger.mjs +85 -3
- package/.dsh-plugin/shared/task-executors.mjs +182 -27
- package/.dsh-plugin/shared/tool-install-preferences.mjs +11 -3
- package/CHANGELOG.md +21 -0
- package/README.i18n.yaml +2 -2
- package/README.md +13 -9
- package/README.zh.md +13 -9
- package/docs/INSTALLATION_GUIDE.zh.md +12 -12
- package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +46 -0
- package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +91 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +5 -3
- package/docs/releases/0.22.0-validation.md +28 -0
- package/package.json +15 -4
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Optimality concerns this surrogate objective, never actual answer quality.
|
|
2
|
+
* Deterministic assignment over retained candidate actions, not actual answer quality.
|
|
3
|
+
* Small spaces enumerate exactly; larger spaces use safe boundary-state dominance
|
|
4
|
+
* and future-aware bounded frontier retention without a global optimality claim.
|
|
6
5
|
*/
|
|
7
6
|
const EPSILON = 1e-12
|
|
8
7
|
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
const choiceSignature = option => {
|
|
10
|
+
if (option.actionId === undefined && option.effort === undefined && option.reasoningEffort === undefined) return option.route
|
|
11
|
+
return JSON.stringify([option.actionId ?? option.route, option.effort ?? option.reasoningEffort ?? null])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const signature = state => JSON.stringify(state.choices.map(choiceSignature))
|
|
15
|
+
|
|
16
|
+
const compareQuality = (left, right) =>
|
|
17
|
+
left.relaxedCount - right.relaxedCount || left.qualityShortfall - right.qualityShortfall
|
|
10
18
|
|
|
11
19
|
function compareStates(left, right, minimizeCost = false) {
|
|
12
20
|
return left.relaxedCount - right.relaxedCount
|
|
@@ -17,69 +25,261 @@ function compareStates(left, right, minimizeCost = false) {
|
|
|
17
25
|
|| compareText(signature(left), signature(right))
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
// Safe DP dominance for states with the same future dependency boundary.
|
|
29
|
+
// Quality is lexicographic and always precedes cost/score in compareStates.
|
|
30
|
+
// Cost/score are Pareto compared, then switches and the deterministic signature
|
|
31
|
+
// break exact ties. Comparisons are strict; no epsilon is used here.
|
|
32
|
+
const dominates = (left, right) => {
|
|
33
|
+
if (left.cost > right.cost) return false
|
|
34
|
+
const quality = compareQuality(left, right)
|
|
35
|
+
if (quality < 0) return true
|
|
36
|
+
if (quality > 0) return false
|
|
37
|
+
if (left.score > right.score) return true
|
|
38
|
+
if (left.score < right.score) return false
|
|
39
|
+
if (left.cost < right.cost) return true
|
|
40
|
+
if (left.cost > right.cost) return false
|
|
41
|
+
if (left.switches !== right.switches) return left.switches < right.switches
|
|
42
|
+
return compareText(signature(left), signature(right)) < 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dominanceOrder = (left, right) =>
|
|
46
|
+
left.cost - right.cost
|
|
47
|
+
|| compareQuality(left, right)
|
|
48
|
+
|| right.score - left.score
|
|
49
|
+
|| left.switches - right.switches
|
|
50
|
+
|| compareText(signature(left), signature(right))
|
|
51
|
+
|
|
20
52
|
export function solveCandidateAssignments({ tasks = [], pools = [], budget = Infinity, beamWidth = 256, exactLimit = 4096, handoffPenalty = 0.015, minimizeCost = false } = {}) {
|
|
21
53
|
if (!tasks.length || tasks.length !== pools.length) return null
|
|
54
|
+
const taskCount = tasks.length
|
|
55
|
+
const taskIndexById = new Map()
|
|
22
56
|
const preceding = new Set()
|
|
23
|
-
for (
|
|
24
|
-
|
|
57
|
+
for (let index = 0; index < taskCount; index++) {
|
|
58
|
+
const task = tasks[index]
|
|
59
|
+
if (!task || !task.id || preceding.has(task.id) || (task.dependsOn ?? []).some(id => !preceding.has(id))) {
|
|
25
60
|
throw new RangeError('Assignment tasks must have unique ids and known dependencies in topological order')
|
|
26
61
|
}
|
|
27
62
|
preceding.add(task.id)
|
|
63
|
+
taskIndexById.set(task.id, index)
|
|
28
64
|
}
|
|
65
|
+
|
|
29
66
|
const constrained = Number.isFinite(budget)
|
|
30
67
|
const options = pools.map(pool => (Array.isArray(pool) ? pool : []).filter(option =>
|
|
31
|
-
Number.isFinite(option.cost) && option.cost >= 0 && Number.isFinite(option.score)
|
|
68
|
+
option && Number.isFinite(option.cost) && option.cost >= 0 && Number.isFinite(option.score)
|
|
32
69
|
&& (!constrained || option.pricingKnown === true)))
|
|
33
70
|
if (options.some(pool => pool.length === 0)) return null
|
|
34
|
-
|
|
71
|
+
|
|
72
|
+
const handoffRoutes = options.map(pool => pool.map(option => option.handoffRoute ?? option.route))
|
|
73
|
+
const maximumScores = options.map(pool => pool.reduce((best, option) => Math.max(best, option.score), -Infinity))
|
|
74
|
+
const dependencyIndexes = tasks.map(task => (task.dependsOn ?? []).map(id => taskIndexById.get(id)))
|
|
75
|
+
const lastUse = new Array(taskCount).fill(-1)
|
|
76
|
+
for (let index = 0; index < taskCount; index++) {
|
|
77
|
+
for (const depIndex of dependencyIndexes[index]) {
|
|
78
|
+
if (index > lastUse[depIndex]) lastUse[depIndex] = index
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const suffixMinimum = new Array(taskCount + 1).fill(0)
|
|
83
|
+
const futureRelaxedLower = new Array(taskCount + 1).fill(0)
|
|
84
|
+
const futureShortfallLower = new Array(taskCount + 1).fill(0)
|
|
35
85
|
let searchSpace = 1
|
|
36
86
|
let searchSpaceCapped = false
|
|
37
|
-
for (let index =
|
|
38
|
-
|
|
87
|
+
for (let index = taskCount - 1; index >= 0; index--) {
|
|
88
|
+
let minCost = Infinity
|
|
89
|
+
let minRelaxedFlag = 1
|
|
90
|
+
let minShortfallAtFlag = Infinity
|
|
91
|
+
for (const option of options[index]) {
|
|
92
|
+
if (option.cost < minCost) minCost = option.cost
|
|
93
|
+
const shortfall = Math.max(0, Number(option.qualityShortfall) || 0)
|
|
94
|
+
const relaxedFlag = shortfall > 0 ? 1 : 0
|
|
95
|
+
if (relaxedFlag < minRelaxedFlag || (relaxedFlag === minRelaxedFlag && shortfall < minShortfallAtFlag)) {
|
|
96
|
+
minRelaxedFlag = relaxedFlag
|
|
97
|
+
minShortfallAtFlag = shortfall
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
suffixMinimum[index] = suffixMinimum[index + 1] + minCost
|
|
101
|
+
futureRelaxedLower[index] = futureRelaxedLower[index + 1] + minRelaxedFlag
|
|
102
|
+
futureShortfallLower[index] = futureShortfallLower[index + 1] + minShortfallAtFlag
|
|
39
103
|
const product = searchSpace * options[index].length
|
|
40
104
|
if (product > Number.MAX_SAFE_INTEGER) searchSpaceCapped = true
|
|
41
105
|
searchSpace = Math.min(Number.MAX_SAFE_INTEGER, product)
|
|
42
106
|
}
|
|
107
|
+
|
|
43
108
|
if (constrained && suffixMinimum[0] > budget + EPSILON) return null
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
109
|
+
const parsedExactLimit = Number(exactLimit)
|
|
110
|
+
const exactThreshold = Number.isFinite(parsedExactLimit)
|
|
111
|
+
? Math.max(1, parsedExactLimit)
|
|
112
|
+
: exactLimit === Infinity ? Number.MAX_SAFE_INTEGER : 4096
|
|
113
|
+
const parsedBeamWidth = Number(beamWidth)
|
|
114
|
+
const width = Number.isFinite(parsedBeamWidth) ? Math.max(1, Math.floor(parsedBeamWidth) || 256) : 256
|
|
115
|
+
const exact = !searchSpaceCapped && searchSpace <= exactThreshold
|
|
116
|
+
|
|
117
|
+
const search = {
|
|
118
|
+
method: exact ? 'exact' : 'beam',
|
|
119
|
+
exact,
|
|
120
|
+
strategy: exact ? 'exact-enumeration' : 'boundary-frontier-lookahead',
|
|
121
|
+
searchSpace,
|
|
122
|
+
searchSpaceCapped,
|
|
123
|
+
expandedStates: 0,
|
|
124
|
+
beamPruned: 0,
|
|
125
|
+
dominancePruned: 0,
|
|
126
|
+
peakFrontier: 0,
|
|
127
|
+
retainedStates: 0,
|
|
128
|
+
beamWidth: width,
|
|
129
|
+
approximate: !exact,
|
|
130
|
+
optimalityScope: 'retained input candidate Cartesian space',
|
|
131
|
+
boundCertified: exact
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Negative handoff penalties historically increase score for mismatches, so an
|
|
135
|
+
// optimistic lookahead must include the maximum possible unassigned reward.
|
|
136
|
+
const unassignedReward = handoffPenalty < 0 ? -handoffPenalty : 0
|
|
47
137
|
let states = [{ choices: [], routesByTask: new Map(), score: 0, cost: 0, switches: 0, relaxedCount: 0, qualityShortfall: 0 }]
|
|
48
|
-
|
|
49
|
-
|
|
138
|
+
|
|
139
|
+
for (let index = 0; index < taskCount; index++) {
|
|
50
140
|
const expanded = []
|
|
51
141
|
for (const state of states) {
|
|
52
|
-
for (
|
|
142
|
+
for (let optionIndex = 0; optionIndex < options[index].length; optionIndex++) {
|
|
143
|
+
const option = options[index][optionIndex]
|
|
53
144
|
const cost = state.cost + option.cost
|
|
54
145
|
if (constrained && cost + suffixMinimum[index + 1] > budget + EPSILON) continue
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
146
|
+
let switches = 0
|
|
147
|
+
for (const depIndex of dependencyIndexes[index]) {
|
|
148
|
+
const route = state.routesByTask.get(tasks[depIndex].id)
|
|
149
|
+
if (route !== undefined && route !== handoffRoutes[index][optionIndex]) switches++
|
|
150
|
+
}
|
|
59
151
|
const penalty = switches * handoffPenalty
|
|
60
152
|
const shortfall = Math.max(0, Number(option.qualityShortfall) || 0)
|
|
61
153
|
const routesByTask = new Map(state.routesByTask)
|
|
62
|
-
routesByTask.set(
|
|
63
|
-
expanded.push({
|
|
64
|
-
|
|
65
|
-
|
|
154
|
+
routesByTask.set(tasks[index].id, handoffRoutes[index][optionIndex])
|
|
155
|
+
expanded.push({
|
|
156
|
+
choices: [...state.choices, { ...option, handoffPenalty: penalty }],
|
|
157
|
+
routesByTask,
|
|
158
|
+
score: state.score + option.score - penalty,
|
|
159
|
+
cost,
|
|
160
|
+
switches: state.switches + switches,
|
|
161
|
+
relaxedCount: state.relaxedCount + (shortfall > 0 ? 1 : 0),
|
|
162
|
+
qualityShortfall: state.qualityShortfall + shortfall
|
|
163
|
+
})
|
|
66
164
|
search.expandedStates++
|
|
67
165
|
}
|
|
68
166
|
}
|
|
69
167
|
if (!expanded.length) return null
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
168
|
+
|
|
169
|
+
if (exact) {
|
|
170
|
+
expanded.sort((left, right) => compareStates(left, right, minimizeCost))
|
|
171
|
+
states = expanded
|
|
172
|
+
} else {
|
|
173
|
+
// Group by the only information future transitions can observe: handoff
|
|
174
|
+
// routes of already assigned tasks that still have a future dependent.
|
|
175
|
+
const groups = new Map()
|
|
176
|
+
for (const state of expanded) {
|
|
177
|
+
const boundary = []
|
|
178
|
+
for (let prev = 0; prev <= index; prev++) {
|
|
179
|
+
if (lastUse[prev] > index) boundary.push([tasks[prev].id, state.routesByTask.get(tasks[prev].id)])
|
|
180
|
+
}
|
|
181
|
+
const key = JSON.stringify(boundary)
|
|
182
|
+
const group = groups.get(key)
|
|
183
|
+
if (group) group.push(state)
|
|
184
|
+
else groups.set(key, [state])
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const compressed = []
|
|
188
|
+
for (const group of groups.values()) {
|
|
189
|
+
group.sort(dominanceOrder)
|
|
190
|
+
const retained = []
|
|
191
|
+
for (const candidate of group) {
|
|
192
|
+
let dominated = false
|
|
193
|
+
for (const keeper of retained) {
|
|
194
|
+
if (dominates(keeper, candidate)) {
|
|
195
|
+
dominated = true
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (dominated) search.dominancePruned++
|
|
200
|
+
else retained.push(candidate)
|
|
201
|
+
}
|
|
202
|
+
for (const state of retained) compressed.push(state)
|
|
78
203
|
}
|
|
79
|
-
|
|
80
|
-
|
|
204
|
+
|
|
205
|
+
const scoreUpperCache = new Map()
|
|
206
|
+
const optimisticScore = state => {
|
|
207
|
+
if (scoreUpperCache.has(state)) return scoreUpperCache.get(state)
|
|
208
|
+
let total = state.score
|
|
209
|
+
for (let future = index + 1; future < taskCount; future++) {
|
|
210
|
+
const deps = dependencyIndexes[future]
|
|
211
|
+
let unassignedDeps = 0
|
|
212
|
+
let assignedCount = 0
|
|
213
|
+
const assignedRoutes = new Map()
|
|
214
|
+
for (const depIndex of deps) {
|
|
215
|
+
if (depIndex > index) unassignedDeps++
|
|
216
|
+
else {
|
|
217
|
+
const route = state.routesByTask.get(tasks[depIndex].id)
|
|
218
|
+
if (route !== undefined) {
|
|
219
|
+
assignedCount++
|
|
220
|
+
assignedRoutes.set(route, (assignedRoutes.get(route) ?? 0) + 1)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Count predecessor routes once, rather than rescanning every edge
|
|
225
|
+
// for every future action. Independent future tasks need no scan.
|
|
226
|
+
if (!assignedCount || handoffPenalty === 0) {
|
|
227
|
+
total += maximumScores[future] + unassignedReward * unassignedDeps
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
let best = -Infinity
|
|
231
|
+
for (let optionIndex = 0; optionIndex < options[future].length; optionIndex++) {
|
|
232
|
+
const option = options[future][optionIndex]
|
|
233
|
+
const matches = assignedRoutes.get(handoffRoutes[future][optionIndex]) ?? 0
|
|
234
|
+
let value = option.score - handoffPenalty * (assignedCount - matches)
|
|
235
|
+
if (unassignedDeps && unassignedReward) value += unassignedReward * unassignedDeps
|
|
236
|
+
if (value > best) best = value
|
|
237
|
+
}
|
|
238
|
+
total += best
|
|
239
|
+
}
|
|
240
|
+
scoreUpperCache.set(state, total)
|
|
241
|
+
return total
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Lookahead is a sorting heuristic only. Future quality is a per-task lower
|
|
245
|
+
// bound and ignores future handoff interactions, so it cannot certify optimality.
|
|
246
|
+
compressed.sort((left, right) => {
|
|
247
|
+
const leftRelaxed = left.relaxedCount + futureRelaxedLower[index + 1]
|
|
248
|
+
const rightRelaxed = right.relaxedCount + futureRelaxedLower[index + 1]
|
|
249
|
+
if (leftRelaxed !== rightRelaxed) return leftRelaxed - rightRelaxed
|
|
250
|
+
const leftShortfall = left.qualityShortfall + futureShortfallLower[index + 1]
|
|
251
|
+
const rightShortfall = right.qualityShortfall + futureShortfallLower[index + 1]
|
|
252
|
+
if (leftShortfall !== rightShortfall) return leftShortfall - rightShortfall
|
|
253
|
+
const leftScore = optimisticScore(left)
|
|
254
|
+
const rightScore = optimisticScore(right)
|
|
255
|
+
const leftCost = left.cost + suffixMinimum[index + 1]
|
|
256
|
+
const rightCost = right.cost + suffixMinimum[index + 1]
|
|
257
|
+
if (minimizeCost) {
|
|
258
|
+
if (leftCost !== rightCost) return leftCost - rightCost
|
|
259
|
+
if (leftScore !== rightScore) return rightScore - leftScore
|
|
260
|
+
} else {
|
|
261
|
+
if (leftScore !== rightScore) return rightScore - leftScore
|
|
262
|
+
if (leftCost !== rightCost) return leftCost - rightCost
|
|
263
|
+
}
|
|
264
|
+
return compareStates(left, right, minimizeCost)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
if (compressed.length > width) {
|
|
268
|
+
states = compressed.slice(0, width)
|
|
269
|
+
search.beamPruned += compressed.length - states.length
|
|
270
|
+
if (constrained && index < taskCount - 1) {
|
|
271
|
+
let cheapest = compressed[0]
|
|
272
|
+
for (const state of compressed) if (compareStates(state, cheapest, true) < 0) cheapest = state
|
|
273
|
+
if (!states.includes(cheapest)) states[width - 1] = cheapest
|
|
274
|
+
}
|
|
275
|
+
} else states = compressed
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (states.length > search.peakFrontier) search.peakFrontier = states.length
|
|
81
279
|
}
|
|
280
|
+
|
|
82
281
|
states.sort((left, right) => compareStates(left, right, minimizeCost))
|
|
282
|
+
search.retainedStates = states.length
|
|
83
283
|
const best = states[0]
|
|
84
284
|
const { routesByTask, ...result } = best
|
|
85
285
|
return { ...result, minimumCostLowerBound: suffixMinimum[0], search }
|
|
@@ -73,8 +73,8 @@ function annotate(channel) {
|
|
|
73
73
|
*/
|
|
74
74
|
export function createPlanFromRoutes(task, availableRoutes, {
|
|
75
75
|
mode = 'single', budgetUsd = 0, installedToolIds = [], runnableToolIds = [],
|
|
76
|
-
pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, cacheReadRatio = 0, cacheWriteRatio = 0,
|
|
77
|
-
directProvider = '', directModel = '', preset = 'balanced', loggedOutToolIds = [],
|
|
76
|
+
pricing = {}, liveBench = null, liveBenchError = '', dataVersions = null, learning = null, routingEvidence = null, cacheReadRatio = 0, cacheWriteRatio = 0,
|
|
77
|
+
directProvider = '', directModel = '', preset = 'balanced', loggedOutToolIds = [], forceOfficialCli = false,
|
|
78
78
|
} = {}) {
|
|
79
79
|
const taskText = clean(task)
|
|
80
80
|
if (!taskText) throw new Error('task must contain text')
|
|
@@ -96,13 +96,23 @@ export function createPlanFromRoutes(task, availableRoutes, {
|
|
|
96
96
|
const channelOf = (provider, model) => {
|
|
97
97
|
const route = routes.find(item => item.provider === provider && item.model === model)
|
|
98
98
|
const key = `${String(provider ?? '')}\0${String(model ?? '')}\0${route?.execution ?? ''}`
|
|
99
|
-
if (!channelCache.has(key))
|
|
99
|
+
if (!channelCache.has(key)) {
|
|
100
|
+
const tool = forceOfficialCli ? toolForProvider(provider) : null
|
|
101
|
+
channelCache.set(key, tool ? {
|
|
102
|
+
kind: 'official-cli', tool: tool.id, label: tool.label,
|
|
103
|
+
detail: '官方 CLI 团队计划仅通过已核验的官方 CLI 执行;使用厂商默认推理档位。',
|
|
104
|
+
} : channelForProvider(provider, installedIds, runnableToolIds, route, loggedOutToolIds))
|
|
105
|
+
}
|
|
100
106
|
return channelCache.get(key)
|
|
101
107
|
}
|
|
102
108
|
const needsImage = detectTaskTypes(taskText).includes('vision')
|
|
103
109
|
const plan = buildPlan({
|
|
104
110
|
text: taskText,
|
|
105
|
-
available: routes
|
|
111
|
+
available: routes.map(route => {
|
|
112
|
+
const channel = channelOf(route.provider, route.model)
|
|
113
|
+
return { ...route, executionChannel: channel.kind,
|
|
114
|
+
reasoningExecution: directRoute ? 'default' : channel.kind === 'harness-llm' ? 'api' : 'unsupported-cli' }
|
|
115
|
+
}),
|
|
106
116
|
mode: directRoute ? 'single' : selectedMode,
|
|
107
117
|
budgetUsd: Math.max(0, Number.isFinite(budgetUsd) ? budgetUsd : 0),
|
|
108
118
|
pricing,
|
|
@@ -110,6 +120,7 @@ export function createPlanFromRoutes(task, availableRoutes, {
|
|
|
110
120
|
liveBenchError,
|
|
111
121
|
dataVersions,
|
|
112
122
|
learning,
|
|
123
|
+
routingEvidence,
|
|
113
124
|
cacheReadRatio,
|
|
114
125
|
cacheWriteRatio,
|
|
115
126
|
preset,
|
|
@@ -157,6 +168,7 @@ export function createPlanFromRoutes(task, availableRoutes, {
|
|
|
157
168
|
recommendedModel: item.recommended,
|
|
158
169
|
estimatedCost: plan.costBreakdown[index]?.estimatedCost ?? null,
|
|
159
170
|
...item.recommendedReasoningEffort ? { recommendedReasoningEffort: item.recommendedReasoningEffort } : {},
|
|
171
|
+
...item.reasoningExecutionNotice ? { reasoningExecutionNotice: item.reasoningExecutionNotice } : {},
|
|
160
172
|
...annotate(channelOf(item.recommendedProvider, item.recommended)),
|
|
161
173
|
verificationChecklist: item.purpose === 'synthesis'
|
|
162
174
|
? ['核对各工作包交付物与依赖', '记录冲突、未解决事项和最终验收结果']
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { estimateAssignedPackage } from './router.mjs'
|
|
2
|
+
import { getOfficialTool } from './official-tool-registry.mjs'
|
|
3
|
+
import { billingPlan } from './subscription-billing.mjs'
|
|
4
|
+
import { parsePackageAssignments, packagePlanKey } from './package-assignments.mjs'
|
|
5
|
+
|
|
6
|
+
const allocatedFields = [
|
|
7
|
+
'recommendedReasoningEffort', 'preferredReasoningEffort', 'reasoningEffort',
|
|
8
|
+
'plannedReasoningEffort', 'requestedReasoningEffort', 'reasoningFit',
|
|
9
|
+
'reasoningOutputMultiplier', 'reasoningExecutionNotice', 'channelTool',
|
|
10
|
+
'channelLabel', 'channelDetail', 'loginRequired', 'executionPreference',
|
|
11
|
+
'executionChannel', 'estimatedCost', 'quality', 'qualitySource', 'pricingSource',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
function withoutAllocation(value) {
|
|
15
|
+
const result = { ...value }
|
|
16
|
+
for (const key of allocatedFields) delete result[key]
|
|
17
|
+
if (Array.isArray(result.dependsOn)) result.dependsOn = [...result.dependsOn]
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function annotation(target, tool) {
|
|
22
|
+
return target.kind === 'api' ? {
|
|
23
|
+
executionChannel: 'harness-llm',
|
|
24
|
+
channelDetail: '用户指定该模型目录 API;使用适配器默认推理档位。',
|
|
25
|
+
reasoningExecutionNotice: '手动分配使用适配器默认推理档位。',
|
|
26
|
+
} : {
|
|
27
|
+
executionChannel: 'official-cli', channelTool: tool.id, channelLabel: tool.label,
|
|
28
|
+
channelDetail: `${tool.label} 使用厂商工具自身配置的默认模型,不代表模型目录中的特定模型。`,
|
|
29
|
+
reasoningExecutionNotice: '使用厂商官方工具默认模型与默认推理档位。',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Apply explicit targets to the existing decomposition; never search or substitute targets. */
|
|
34
|
+
export function applyManualPackagePlan(plan, {
|
|
35
|
+
task, packageAssignments, packagePlanKey: expectedKey,
|
|
36
|
+
} = {}, routes, dataOptions = {}) {
|
|
37
|
+
const assignments = parsePackageAssignments(packageAssignments)
|
|
38
|
+
if (!assignments) throw new TypeError('手动分配需要提供全部工作包目标。')
|
|
39
|
+
const packages = plan?.team?.workPackages
|
|
40
|
+
if (plan?.routingBypassed || plan?.mode === 'direct' || !Array.isArray(packages)
|
|
41
|
+
|| !packages.length || packages.length > 32) {
|
|
42
|
+
throw new Error('手动分配需要当前任务的工作包计划,不能用于全局指定模型。')
|
|
43
|
+
}
|
|
44
|
+
const key = packagePlanKey(plan, task)
|
|
45
|
+
if (!key || expectedKey !== key) throw new Error('工作包计划已变化,请重新确认分配。')
|
|
46
|
+
const ids = packages.map(item => item?.id)
|
|
47
|
+
if (new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string' || !id)) {
|
|
48
|
+
throw new Error('工作包计划包含无效或重复标识。')
|
|
49
|
+
}
|
|
50
|
+
const chosenIds = Object.keys(assignments)
|
|
51
|
+
if (chosenIds.length !== ids.length || chosenIds.some(id => !ids.includes(id))) {
|
|
52
|
+
throw new Error('请为全部工作包分配目标,且不能添加其他工作包。')
|
|
53
|
+
}
|
|
54
|
+
const catalog = Array.isArray(routes) ? routes : []
|
|
55
|
+
const choices = packages.map(item => {
|
|
56
|
+
const target = assignments[item.id]
|
|
57
|
+
if (!target) throw new Error(`工作包 ${item.id} 缺少目标。`)
|
|
58
|
+
if (target.kind === 'tool') {
|
|
59
|
+
const tool = getOfficialTool(target.toolId)
|
|
60
|
+
if (!tool) throw new Error(`未知的官方工具:${target.toolId}`)
|
|
61
|
+
return { target, tool, provider: 'official-tool', model: target.toolId }
|
|
62
|
+
}
|
|
63
|
+
const matches = catalog.filter(route => route.provider === target.provider && route.model === target.model)
|
|
64
|
+
if (matches.length !== 1) throw new Error(`指定模型 ${target.provider}/${target.model} 不在唯一可用目录中。`)
|
|
65
|
+
const route = matches[0]
|
|
66
|
+
const billing = billingPlan(route, catalog)
|
|
67
|
+
if (billing.mode === 'subscription-only' || billing.subscription?.kind === 'plan-key') {
|
|
68
|
+
throw new Error(`模型 ${target.provider}/${target.model} 使用订阅路线,不能作为手动 API 目标。`)
|
|
69
|
+
}
|
|
70
|
+
if (item.type === 'vision' && Array.isArray(route.inputModalities)
|
|
71
|
+
&& route.inputModalities.length && !route.inputModalities.some(value => String(value).toLowerCase() === 'image')) {
|
|
72
|
+
throw new Error(`工作包 ${item.id} 需要图像模型,所选模型仅声明其他输入能力。`)
|
|
73
|
+
}
|
|
74
|
+
return { target, route, provider: target.provider, model: target.model }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const costBreakdown = packages.map((item, index) => {
|
|
78
|
+
const choice = choices[index]
|
|
79
|
+
const estimate = choice.route
|
|
80
|
+
? estimateAssignedPackage(choice.route, item, task, plan.complexity?.band ?? 'balanced', dataOptions)
|
|
81
|
+
: { estimatedCost: null, quality: null, qualitySource: 'unknown', pricingSource: 'unknown',
|
|
82
|
+
estimateSource: 'official-tool-unknown', handoffPenalty: 0 }
|
|
83
|
+
return { ...withoutAllocation(estimate), stage: index + 1, purpose: item.purpose,
|
|
84
|
+
difficulty: item.difficulty, provider: choice.provider, model: choice.model,
|
|
85
|
+
estimatedCost: Number.isFinite(estimate.estimatedCost) && estimate.estimatedCost >= 0 ? estimate.estimatedCost : null,
|
|
86
|
+
quality: estimate.quality ?? null, qualitySource: estimate.qualitySource ?? 'unknown',
|
|
87
|
+
pricingSource: estimate.pricingSource ?? 'unknown', selectionSource: 'manual',
|
|
88
|
+
manualAssignment: { ...choice.target }, ...annotation(choice.target, choice.tool) }
|
|
89
|
+
})
|
|
90
|
+
const workPackages = packages.map((item, index) => {
|
|
91
|
+
const choice = choices[index], cost = costBreakdown[index]
|
|
92
|
+
return { ...withoutAllocation(item), recommendedProvider: choice.provider,
|
|
93
|
+
recommendedModel: choice.model, estimatedCost: cost.estimatedCost,
|
|
94
|
+
qualitySource: cost.qualitySource, pricingSource: cost.pricingSource,
|
|
95
|
+
manualAssignment: { ...choice.target }, selectionSource: 'manual',
|
|
96
|
+
algorithmRecommendation: { provider: item.recommendedProvider, model: item.recommendedModel,
|
|
97
|
+
channel: item.executionChannel }, ...annotation(choice.target, choice.tool) }
|
|
98
|
+
})
|
|
99
|
+
const originalSubtasks = new Map((plan.subtasks ?? []).map(item => [item.id, item]))
|
|
100
|
+
const subtasks = workPackages.map(item => ({ ...withoutAllocation(originalSubtasks.get(item.id) ?? item),
|
|
101
|
+
id: item.id, name: item.name, ...(item.objective !== undefined ? { objective: item.objective } : {}),
|
|
102
|
+
type: item.type, purpose: item.purpose, dependsOn: [...(item.dependsOn ?? [])],
|
|
103
|
+
recommendedProvider: item.recommendedProvider, recommended: item.recommendedModel,
|
|
104
|
+
estimatedCost: item.estimatedCost, qualitySource: item.qualitySource, pricingSource: item.pricingSource,
|
|
105
|
+
manualAssignment: { ...item.manualAssignment }, selectionSource: 'manual',
|
|
106
|
+
algorithmRecommendation: { ...item.algorithmRecommendation },
|
|
107
|
+
...annotation(item.manualAssignment, choices[ids.indexOf(item.id)].tool) }))
|
|
108
|
+
// The public total stays unknown when any official-tool or API price is unknown.
|
|
109
|
+
const pricingComplete = costBreakdown.every(item => item.estimatedCost !== null)
|
|
110
|
+
const estimatedCost = pricingComplete ? costBreakdown.reduce((sum, item) => sum + item.estimatedCost, 0) : null
|
|
111
|
+
const budget = Number(plan.optimization?.budgetUsd)
|
|
112
|
+
const budgetUsd = Number.isFinite(budget) && budget > 0 ? budget : 0
|
|
113
|
+
const budgetExceeded = estimatedCost === null ? null : budgetUsd > 0 ? estimatedCost > budgetUsd : false
|
|
114
|
+
const selection = index => ({ provider: choices[index].provider, model: choices[index].model,
|
|
115
|
+
estimatedCost: costBreakdown[index].estimatedCost, qualitySource: costBreakdown[index].qualitySource,
|
|
116
|
+
pricingSource: costBreakdown[index].pricingSource, selectionSource: 'manual',
|
|
117
|
+
...annotation(choices[index].target, choices[index].tool) })
|
|
118
|
+
const synthesisIndex = packages.findLastIndex(item => item.purpose === 'synthesis')
|
|
119
|
+
const originalOptimization = plan.optimization ?? {}
|
|
120
|
+
const clearedPlan = withoutAllocation(plan)
|
|
121
|
+
return { ...clearedPlan, assignmentMode: 'manual', manualAssignments: assignments, packagePlanKey: key,
|
|
122
|
+
selected: selection(0), synthesizer: plan.synthesizer ? selection(synthesisIndex >= 0 ? synthesisIndex : packages.length - 1) : null,
|
|
123
|
+
subtasks, team: { ...plan.team, workPackages }, costBreakdown, estimatedCost,
|
|
124
|
+
originalAlgorithmEstimate: plan.originalAlgorithmEstimate ?? plan.estimatedCost ?? null,
|
|
125
|
+
unassignableTasks: [], ...annotation(choices[0].target, choices[0].tool),
|
|
126
|
+
reason: '已按用户选择逐包分配模型与官方工具;执行目标不会因算法评分或预算而自动替换。',
|
|
127
|
+
pricingNotice: estimatedCost === null
|
|
128
|
+
? '手动目标中有费用未知的模型或官方工具,无法确认总费用;工具费用不按原推荐模型计价。'
|
|
129
|
+
: '费用按手动选择的 API、默认推理档位和估计资源重算,不是供应商账单或硬性支出上限。',
|
|
130
|
+
qualityNotice: '手动分配由用户决定目标,尚未验证所选目标的任务质量;算法质量门槛不代表手动方案已经通过验证。',
|
|
131
|
+
toolNotice: '官方工具使用厂商自身配置的默认模型与推理档位,执行前由 Host 核验工具是否可用。',
|
|
132
|
+
optimization: { ...originalOptimization, manualAssignment: true, solver: 'manual-package-assignment',
|
|
133
|
+
search: null, optimalityScope: null, budgetUsd, budgetExceeded,
|
|
134
|
+
budgetFeasible: estimatedCost === null ? null : !budgetExceeded,
|
|
135
|
+
pricingComplete, qualityEvidenceComplete: false, constraintRelaxed: null,
|
|
136
|
+
baselineAllStrongCost: null, estimatedSavings: null, minimumFeasibleCost: null,
|
|
137
|
+
jointEffortCandidates: 0, qualityEvidenceCount: 0,
|
|
138
|
+
resourceEvidenceCount: costBreakdown.filter(item => item.estimateSource === 'recorded-resource-evidence').length,
|
|
139
|
+
distinctRoutes: new Set(choices.map(item => JSON.stringify(item.target))).size,
|
|
140
|
+
handoffCount: null, paretoPruned: 0, candidateTruncated: 0, qualityFiltered: 0,
|
|
141
|
+
qualityConstraintsEnforced: false },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -12,6 +12,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
12
12
|
import { runOfficialTool, officialToolExecutionCapabilities, officialToolReadiness } from './official-tool-executor.mjs'
|
|
13
13
|
import { toolForProvider } from './official-tool-registry.mjs'
|
|
14
14
|
import { redactDiagnostic, verifiedDiagnostic } from './task-executors.mjs'
|
|
15
|
+
import { unsupportedCliReasoning } from './reasoning-execution.mjs'
|
|
15
16
|
|
|
16
17
|
const MAX_GIT_OUTPUT = 64_000_000
|
|
17
18
|
const GIT_TIMEOUT_MS = 120_000
|
|
@@ -455,12 +456,14 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
455
456
|
if (signal?.aborted) return { status: 'cancelled', workspace: runWorkspace, results: completed,
|
|
456
457
|
...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
|
|
457
458
|
const cliModel = teamCliModel(item, configuredCliModels)
|
|
459
|
+
const prompt = packagePrompt(task, item, [...context, ...completed])
|
|
460
|
+
const startedAt = Date.now()
|
|
458
461
|
let result
|
|
459
462
|
try {
|
|
460
463
|
result = await runTool({
|
|
461
464
|
toolId: item.toolId,
|
|
462
465
|
modelId: cliModel,
|
|
463
|
-
task:
|
|
466
|
+
task: prompt,
|
|
464
467
|
workspace: runWorkspace,
|
|
465
468
|
mode,
|
|
466
469
|
isolatedRoot: isolated?.isolatedRoot,
|
|
@@ -474,6 +477,7 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
474
477
|
// ledger and retry even when a later adapter rejects before returning.
|
|
475
478
|
result = executionException(error, signal)
|
|
476
479
|
}
|
|
480
|
+
const durationMs = Date.now() - startedAt
|
|
477
481
|
const reportedModel = reportedCliModel(item.toolId, result.reportedModel)
|
|
478
482
|
const modelMismatch = result.status === 'succeeded' && cliModel && reportedModel
|
|
479
483
|
&& cliModel !== reportedModel
|
|
@@ -481,6 +485,9 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
|
|
|
481
485
|
completed.push({ id: item.id, name: item.name,
|
|
482
486
|
...(item.objective ? { objective: item.objective } : {}), provider: item.recommendedProvider,
|
|
483
487
|
recommendedModel: item.recommendedModel, toolId: item.toolId,
|
|
488
|
+
...unsupportedCliReasoning({ reasoningEffort: item.recommendedReasoningEffort }),
|
|
489
|
+
...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}),
|
|
490
|
+
inputChars: prompt.length,
|
|
484
491
|
requestedCliModel: Object.hasOwn(result, 'requestedModel') ? result.requestedModel : cliModel,
|
|
485
492
|
actualModel: reportedModel,
|
|
486
493
|
...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
|
|
@@ -428,20 +428,22 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
428
428
|
installArgs: [],
|
|
429
429
|
uninstallArgs: [],
|
|
430
430
|
probeExecutables: ['hermes'],
|
|
431
|
-
probeNote: '
|
|
431
|
+
probeNote: '支持官方源码脚本安装;自定义源码安装到统一根/hermes-agent,插件使用该源码的 .hermes/bin 入口。配置、登录和历史保留原位置。桌面/MSIX 别名暂不用于此入口。登录使用 hermes auth。',
|
|
432
432
|
providerHints: ['hermes', 'nousresearch'],
|
|
433
433
|
scriptBinaryNames: Object.freeze(['hermes', 'hermes.exe']),
|
|
434
434
|
installMethods: Object.freeze([
|
|
435
435
|
scriptMethod('script-bash', 'curl | bash', {
|
|
436
436
|
shell: 'bash', scriptUrl: 'https://hermes-agent.nousresearch.com/install.sh',
|
|
437
|
-
platforms: ['linux', 'darwin'], supportsInstallDir:
|
|
437
|
+
platforms: ['linux', 'darwin'], supportsInstallDir: true, sharedDefaultBin: true,
|
|
438
|
+
installDirSubdir: 'hermes-agent', installDirSuffix: '.hermes/bin', installArg: ['--dir'],
|
|
438
439
|
extraArgs: ['--non-interactive'],
|
|
439
440
|
verify: Object.freeze({ mustInclude: ['https://github.com/NousResearch/hermes-agent.git', 'hermes_cli/source_completion.py'] }),
|
|
440
441
|
uninstallUnsupportedReason: '请在官方工具终端运行 hermes uninstall 卸载源码安装;保留配置与历史时按向导选择。桌面/MSIX 安装请使用系统应用管理。',
|
|
441
442
|
}),
|
|
442
443
|
scriptMethod('script-powershell', 'irm | iex', {
|
|
443
444
|
shell: 'powershell', scriptUrl: 'https://hermes-agent.nousresearch.com/install.ps1',
|
|
444
|
-
platforms: ['win32'], supportsInstallDir:
|
|
445
|
+
platforms: ['win32'], supportsInstallDir: true,
|
|
446
|
+
installDirSubdir: 'hermes-agent', installDirSuffix: '.hermes/bin', installArg: ['-InstallDir'],
|
|
445
447
|
extraArgs: ['-NonInteractive'],
|
|
446
448
|
verify: Object.freeze({ mustInclude: ['https://github.com/NousResearch/hermes-agent.git', 'hermes_cli/source_completion.py'] }),
|
|
447
449
|
uninstallUnsupportedReason: '请在官方工具终端运行 hermes uninstall 卸载源码安装;保留配置与历史时按向导选择。桌面/MSIX 安装请使用系统应用管理。',
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* accepts a command, package name, URL, argument vector, or executable path.
|
|
7
7
|
*/
|
|
8
8
|
import { getOfficialTool, installMethodsFor } from './official-tool-registry.mjs'
|
|
9
|
+
import { parsePackageAssignments } from './package-assignments.mjs'
|
|
9
10
|
import {
|
|
10
11
|
parseTerminalRead,
|
|
11
12
|
parseTerminalResize,
|
|
@@ -157,6 +158,14 @@ export function parseRunRequest(value) {
|
|
|
157
158
|
if (request.executionMode !== undefined && !['read-only', 'workspace-write'].includes(request.executionMode)) throw new TypeError('executionMode is invalid')
|
|
158
159
|
if (request.executionEnvironment !== undefined && !['harness', 'cli-native'].includes(request.executionEnvironment)) throw new TypeError('executionEnvironment is invalid')
|
|
159
160
|
const officialTeam = request.executor === 'official-team'
|
|
161
|
+
if (request.assignmentMode !== undefined && !['algorithm', 'manual'].includes(request.assignmentMode)) throw new TypeError('assignmentMode is invalid')
|
|
162
|
+
const manual = request.assignmentMode === 'manual'
|
|
163
|
+
if (manual && (officialTeam || provider || model)) throw new TypeError('manual assignments require routed work packages without an explicit global route')
|
|
164
|
+
if (!manual && (request.packageAssignments !== undefined || request.packagePlanKey !== undefined)) throw new TypeError('package assignments require manual assignmentMode')
|
|
165
|
+
const packageAssignments = manual ? parsePackageAssignments(request.packageAssignments) : undefined
|
|
166
|
+
if (manual && (!packageAssignments || typeof request.packagePlanKey !== 'string' || request.packagePlanKey.length > 200 || !request.packagePlanKey)) {
|
|
167
|
+
throw new TypeError('manual assignments require packageAssignments and a current packagePlanKey')
|
|
168
|
+
}
|
|
160
169
|
if (officialTeam && (request.planMode !== 'team' || provider || model)) throw new TypeError('official-team requires a team plan without an explicit route')
|
|
161
170
|
if (!officialTeam && request.executionMode === 'workspace-write') throw new TypeError('workspace-write requires official-team')
|
|
162
171
|
if (request.executionEnvironment === 'cli-native' && (!officialTeam || request.executionMode !== 'workspace-write')) {
|
|
@@ -170,6 +179,7 @@ export function parseRunRequest(value) {
|
|
|
170
179
|
...(officialTeam ? { executor: 'official-team', executionMode: request.executionMode ?? 'read-only',
|
|
171
180
|
...(request.executionEnvironment === undefined ? {} : { executionEnvironment: request.executionEnvironment }) } : {}),
|
|
172
181
|
...(provider ? { provider, model } : {}),
|
|
182
|
+
...(manual ? { assignmentMode: 'manual', packageAssignments, packagePlanKey: request.packagePlanKey } : {}),
|
|
173
183
|
...(['single', 'team'].includes(request.planMode) ? { planMode: request.planMode } : {}),
|
|
174
184
|
...(['economy', 'balanced', 'quality'].includes(request.preset) ? { preset: request.preset } : {}),
|
|
175
185
|
...(request.budgetUsd !== undefined ? { budgetUsd: request.budgetUsd } : {}),
|
|
@@ -594,6 +594,7 @@ export async function assertVendorInstallDirectory(tool, plan, selectedPreferenc
|
|
|
594
594
|
let name
|
|
595
595
|
try { name = JSON.parse(packageText).name } catch { throw new Error('安装目录已有无法核验归属的 package.json,已停止运行官方脚本。') }
|
|
596
596
|
const ownPackage = tool.package || (tool.id === 'stepcode' ? '@step-harness/coding-agent' : '')
|
|
597
|
+
|| (tool.id === 'hermes' ? 'hermes-agent' : '')
|
|
597
598
|
if (!name || name !== ownPackage) throw new Error('安装目录已有其他工具或项目的 package.json,已停止运行官方脚本。')
|
|
598
599
|
}
|
|
599
600
|
for (const other of OFFICIAL_TOOLS) {
|