@ljwei-stak/dsh-model-router 0.13.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 +3092 -0
- package/.dsh-plugin/index.mjs +1651 -0
- package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
- package/.dsh-plugin/shared/harness-plan.mjs +179 -0
- package/.dsh-plugin/shared/livebench.mjs +264 -0
- package/.dsh-plugin/shared/model-profiles.mjs +142 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
- package/.dsh-plugin/shared/router-state.mjs +207 -0
- package/.dsh-plugin/shared/router.mjs +1134 -0
- package/.dsh-plugin/shared/routing-presets.mjs +49 -0
- package/.dsh-plugin/shared/run-ledger.mjs +348 -0
- package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
- package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
- package/.dsh-plugin/shared/task-executors.mjs +1154 -0
- package/.dsh-plugin/shared/tool-health.mjs +311 -0
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
- package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
- package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
- package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
- package/CHANGELOG.md +36 -0
- package/INSTALLATION_GUIDE.zh.md +134 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +53 -0
- package/README.i18n.yaml +3 -0
- package/README.md +424 -0
- package/README.zh.md +413 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +96 -0
- package/docs/assets/workbench-usage.svg +119 -0
- package/package.json +161 -0
|
@@ -0,0 +1,1651 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { isAbsolute } from 'node:path'
|
|
5
|
+
import z from '@deepseek-ai/schemastery'
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
import { DEFAULT_ROUTER_SETTINGS, modelMetadata } from './shared/router.mjs'
|
|
8
|
+
import { DEFAULT_ROUTING_PRESET, normalizeRoutingPreset, routingPreset } from './shared/routing-presets.mjs'
|
|
9
|
+
import { HEALTH_CACHE_MS, apiKeyEnvPresent, healthCache, runHealthCheck, subscriptionLoginOf } from './shared/tool-health.mjs'
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_COOLDOWN_MINUTES, billingOverview, createQuotaTracker, detectQuotaExhaustion, parseQuotaPatterns, vendorKey,
|
|
12
|
+
} from './shared/subscription-billing.mjs'
|
|
13
|
+
import { createRouterState } from './shared/router-state.mjs'
|
|
14
|
+
import {
|
|
15
|
+
applyQualityBiases, budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
|
|
16
|
+
routeQualityBiases, spending, storedResults, actualCost, teamExecutionResults,
|
|
17
|
+
} from './shared/run-ledger.mjs'
|
|
18
|
+
import { routeBoundaries } from './shared/security-boundaries.mjs'
|
|
19
|
+
import { createPlanFromRoutes } from './shared/harness-plan.mjs'
|
|
20
|
+
import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
|
|
21
|
+
import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
|
|
22
|
+
import {
|
|
23
|
+
getOfficialTool,
|
|
24
|
+
installCommandLine,
|
|
25
|
+
toolForProvider,
|
|
26
|
+
} from './shared/official-tool-registry.mjs'
|
|
27
|
+
import { officialToolExecutionCapabilities, officialToolReadiness, runOfficialTool } from './shared/official-tool-executor.mjs'
|
|
28
|
+
import { runOfficialTask, runOfficialTeam, sessionWorkspace } from './shared/official-team-runtime.mjs'
|
|
29
|
+
import { downstreamPackageIds, executeAssignmentPlan, rerunAssignmentPackage, taskTextProblem } from './shared/task-executors.mjs'
|
|
30
|
+
import {
|
|
31
|
+
probeAllTools,
|
|
32
|
+
probeToolWith,
|
|
33
|
+
startInstall,
|
|
34
|
+
cancelInstall,
|
|
35
|
+
installStatus,
|
|
36
|
+
installedToolIds,
|
|
37
|
+
defaultRunner,
|
|
38
|
+
} from './shared/official-tools-runtime.mjs'
|
|
39
|
+
|
|
40
|
+
// Cordis plugin name, matching the profile entry id; unchanged by the 0.13.0 package rename.
|
|
41
|
+
export const name = 'model-router-galgame'
|
|
42
|
+
export const inject = ['commands', 'llm', 'tools', 'typert', 'sandboxPolicy', 'sandbox']
|
|
43
|
+
|
|
44
|
+
export const Config = z.object({
|
|
45
|
+
budgetUsd: z.number().min(0).max(1_000_000).default(DEFAULT_ROUTER_SETTINGS.budgetUsd).volatile(),
|
|
46
|
+
maxConsultOutputChars: z.number().step(1).min(500).max(50_000).default(12_000).volatile(),
|
|
47
|
+
modelProfilesJson: z.string().max(32_000).default('[]').volatile(),
|
|
48
|
+
routingPreset: z.union(['economy', 'balanced', 'quality']).default(DEFAULT_ROUTING_PRESET).volatile(),
|
|
49
|
+
dailyBudgetUsd: z.number().min(0).max(1_000_000).default(0).volatile(),
|
|
50
|
+
monthlyBudgetUsd: z.number().min(0).max(1_000_000).default(0).volatile(),
|
|
51
|
+
overBudgetAction: z.union(['downgrade', 'pause']).default('downgrade').volatile(),
|
|
52
|
+
reviewMode: z.union(['off', 'sample', 'always']).default('off').volatile(),
|
|
53
|
+
reviewSampleRate: z.number().min(0).max(1).default(0.2).volatile(),
|
|
54
|
+
allowManualReassign: z.boolean().default(true).volatile(),
|
|
55
|
+
confirmUnsandboxedCli: z.boolean().default(true).volatile(),
|
|
56
|
+
subscriptionCooldownMinutes: z.number().step(1).min(1).max(10_080).default(DEFAULT_COOLDOWN_MINUTES).volatile(),
|
|
57
|
+
quotaPatternsJson: z.string().max(8_000).default('{}').volatile(),
|
|
58
|
+
onSubscriptionFailure: z.union(['ask', 'api', 'fail']).default('ask').volatile(),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const JSON_OUTPUT = {
|
|
62
|
+
schema: { type: 'json' },
|
|
63
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function jsonValue(value) {
|
|
67
|
+
return JSON.parse(JSON.stringify(value))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function valueOf(config, key, fallback) {
|
|
71
|
+
const value = config?.[key]
|
|
72
|
+
return value !== undefined && typeof value?.get === 'function' ? value.get() : value ?? fallback
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function finiteNumber(value, fallback) {
|
|
76
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
80
|
+
return Math.min(maximum, Math.max(minimum, Math.floor(finiteNumber(value, fallback))))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function text(value) {
|
|
84
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function errorText(error) {
|
|
88
|
+
if (error instanceof Error && error.message) return error.message
|
|
89
|
+
try { return String(error) } catch { return 'unknown error' }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function throwIfAborted(signal) {
|
|
93
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('operation aborted')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function routeKey(route) {
|
|
97
|
+
return `${route.provider}\u0000${route.model}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function configuredRoutesWithProfiles(routes, config) {
|
|
101
|
+
const profiles = parseModelProfilesJson(valueOf(config, 'modelProfilesJson', '[]'))
|
|
102
|
+
return applyModelProfiles(routes, profiles)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function uniqueStrings(values) {
|
|
106
|
+
return [...new Set(values.map(text).filter(Boolean))]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read only exact provider/model routes published by the official LLM service. */
|
|
110
|
+
export async function discoverConfiguredRoutes(ctx, signal) {
|
|
111
|
+
throwIfAborted(signal)
|
|
112
|
+
let providers
|
|
113
|
+
try { providers = ctx.llm.listProviders() } catch { return [] }
|
|
114
|
+
const routes = new Map()
|
|
115
|
+
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
116
|
+
const provider = text(typeof entry === 'string' ? entry : entry?.id)
|
|
117
|
+
if (!provider) continue
|
|
118
|
+
throwIfAborted(signal)
|
|
119
|
+
let models
|
|
120
|
+
try { models = await ctx.llm.listModels(provider) } catch {
|
|
121
|
+
throwIfAborted(signal)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
for (const listed of Array.isArray(models) ? models : []) {
|
|
125
|
+
const model = text(listed?.id ?? listed?.model)
|
|
126
|
+
if (!model) continue
|
|
127
|
+
throwIfAborted(signal)
|
|
128
|
+
let resolved = listed
|
|
129
|
+
try { resolved = await ctx.llm.resolveModelInfo(provider, model, signal) } catch {
|
|
130
|
+
throwIfAborted(signal)
|
|
131
|
+
}
|
|
132
|
+
const reasoning = resolved?.reasoning
|
|
133
|
+
const route = {
|
|
134
|
+
provider,
|
|
135
|
+
model,
|
|
136
|
+
name: text(resolved?.name ?? listed?.name) || model,
|
|
137
|
+
inputModalities: uniqueStrings(Array.isArray(resolved?.inputModalities)
|
|
138
|
+
? resolved.inputModalities : Array.isArray(listed?.inputModalities) ? listed.inputModalities : []),
|
|
139
|
+
reasoningKnown: reasoning !== undefined && reasoning !== null,
|
|
140
|
+
reasoningEfforts: uniqueStrings(Array.isArray(reasoning?.efforts) ? reasoning.efforts.map(item => item?.id ?? item) : []),
|
|
141
|
+
...text(reasoning?.defaultEffort) ? { defaultReasoningEffort: text(reasoning.defaultEffort) } : {},
|
|
142
|
+
}
|
|
143
|
+
routes.set(routeKey(route), route)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...routes.values()].sort((left, right) => routeKey(left).localeCompare(routeKey(right)))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Router runtime state: health cache, run ledger, budget and ratings.
|
|
151
|
+
|
|
152
|
+
let routerState = null
|
|
153
|
+
|
|
154
|
+
/** Persistent Host state (DSH home). Tests point DSH_HOME at a temp dir before the first call. */
|
|
155
|
+
export function routerStateStore() {
|
|
156
|
+
routerState ??= createRouterState()
|
|
157
|
+
return routerState
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function savedState() {
|
|
161
|
+
try { return await routerStateStore().read() }
|
|
162
|
+
catch { return { onboarding: { completedAt: null }, health: null, runs: [] } }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const NOTICE_DAYS = 7
|
|
166
|
+
/** Router notices (for example a corrupt state file that was backed up) from the last week. */
|
|
167
|
+
function recentNotices(saved, now = Date.now()) {
|
|
168
|
+
return (saved?.notices ?? []).filter(item => Number.isFinite(item?.at) && now - item.at < NOTICE_DAYS * 86_400_000)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Health and quota caches follow the shared state file: every call re-reads it
|
|
173
|
+
* (the store caches by mtime/size/inode, so an unchanged file costs one stat)
|
|
174
|
+
* and adopts marks and clears made by other processes without a restart.
|
|
175
|
+
*/
|
|
176
|
+
async function hydrateHealth() {
|
|
177
|
+
const saved = await savedState()
|
|
178
|
+
healthCache.sync(saved)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Subscription quota: which subscriptions hit their limit, and until when.
|
|
182
|
+
let quotaTracker = null
|
|
183
|
+
function quotaStore() {
|
|
184
|
+
quotaTracker ??= createQuotaTracker({ persist: (snapshot, change) => routerStateStore().saveQuota(snapshot, change) })
|
|
185
|
+
return quotaTracker
|
|
186
|
+
}
|
|
187
|
+
async function hydrateQuota() {
|
|
188
|
+
const saved = await savedState()
|
|
189
|
+
quotaStore().sync(saved.quota)
|
|
190
|
+
return quotaStore()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Current quota marks after adopting other processes' changes (workbench and tests). */
|
|
194
|
+
export async function currentQuotaSnapshot() {
|
|
195
|
+
return (await hydrateQuota()).snapshot()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function quotaPatterns(config) {
|
|
199
|
+
return parseQuotaPatterns(valueOf(config, 'quotaPatternsJson', '{}'))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function subscriptionFailureAction(config) {
|
|
203
|
+
const value = valueOf(config, 'onSubscriptionFailure', 'ask')
|
|
204
|
+
return value === 'api' || value === 'fail' ? value : 'ask'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Steps paused after a non-quota subscription failure, for the session model and the workbench. */
|
|
208
|
+
function pausedSteps(execution) {
|
|
209
|
+
return (execution?.packages ?? []).filter(item => item?.paused).map(item => ({
|
|
210
|
+
packageId: item.id, name: item.name, provider: item.provider, model: item.model,
|
|
211
|
+
reason: item.pause?.reason ?? item.error ?? '', detail: item.pause?.detail ?? '', choices: item.pause?.choices ?? ['api', 'subscription', 'cancel'],
|
|
212
|
+
}))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const SUBSCRIPTION_PAUSE_NOTICE = '有步骤在订阅调用失败(非额度用尽或限流)后已暂停,下游步骤在等待,未自动改用 API Key。请把 awaitingConfirmation 中的原因和错误告诉用户,按用户选择调用 model_router_rerun_step,subscriptionChoice 为 api(改用 API 重试,会触发审批)、subscription(重试订阅)或 cancel(取消)。'
|
|
216
|
+
|
|
217
|
+
function cooldownMinutes(config) {
|
|
218
|
+
return boundedInteger(valueOf(config, 'subscriptionCooldownMinutes', DEFAULT_COOLDOWN_MINUTES), DEFAULT_COOLDOWN_MINUTES, 1, 10_080)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Subscription-first billing context for the executor; `options.billing === false` disables it. */
|
|
222
|
+
async function billingContext(config, options = {}) {
|
|
223
|
+
if (options.billing === false) return null
|
|
224
|
+
const quota = await hydrateQuota()
|
|
225
|
+
await hydrateHealth()
|
|
226
|
+
return {
|
|
227
|
+
quota,
|
|
228
|
+
cooldownMinutes: cooldownMinutes(config),
|
|
229
|
+
extraPatterns: quotaPatterns(config).patterns,
|
|
230
|
+
onFailure: subscriptionFailureAction(config),
|
|
231
|
+
loginBillingFor: toolId => subscriptionLoginOf(healthCache.loginState(toolId)),
|
|
232
|
+
loginDetailFor: toolId => healthCache.loginState(toolId),
|
|
233
|
+
now: Date.now,
|
|
234
|
+
...(options.billing && typeof options.billing === 'object' ? options.billing : {}),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Health-check billing table: subscription state and API availability per provider. */
|
|
239
|
+
export async function billingHealth(ctx, config, { signal } = {}) {
|
|
240
|
+
const quota = await hydrateQuota()
|
|
241
|
+
await hydrateHealth()
|
|
242
|
+
let routes = []
|
|
243
|
+
try { routes = configuredRoutesWithProfiles(await discoverConfiguredRoutes(ctx, signal), config) } catch { routes = [] }
|
|
244
|
+
const overview = billingOverview(routes, {
|
|
245
|
+
quota,
|
|
246
|
+
loginFor: toolId => subscriptionLoginOf(healthCache.loginState(toolId)),
|
|
247
|
+
apiKeyEnvFor: toolId => apiKeyEnvPresent(toolId),
|
|
248
|
+
})
|
|
249
|
+
return { ...overview, cooldownMinutes: cooldownMinutes(config), quotaPatternErrors: quotaPatterns(config).errors }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Team and direct CLI runs edit through the official CLI only, so a quota hit
|
|
254
|
+
* there is recorded (and later steps skip that subscription) but not retried
|
|
255
|
+
* on an API key automatically.
|
|
256
|
+
*/
|
|
257
|
+
async function noteCliQuota(config, toolId, textValue) {
|
|
258
|
+
if (!toolId || !text(textValue)) return null
|
|
259
|
+
const info = detectQuotaExhaustion(String(textValue), { vendor: vendorKey({ toolId }), extraPatterns: quotaPatterns(config).patterns })
|
|
260
|
+
if (!info) return null
|
|
261
|
+
const quota = await hydrateQuota()
|
|
262
|
+
const entry = quota.mark(`cli:${toolId}`, { ...info, detail: String(textValue).slice(0, 300) }, { cooldownMinutes: cooldownMinutes(config) })
|
|
263
|
+
const until = new Date(entry.until)
|
|
264
|
+
const pad = value => String(value).padStart(2, '0')
|
|
265
|
+
const at = `${until.getMonth() + 1}-${until.getDate()} ${pad(until.getHours())}:${pad(until.getMinutes())}`
|
|
266
|
+
return {
|
|
267
|
+
from: 'subscription', to: null, kind: info.kind, until: entry.until, source: info.source, detail: entry.detail,
|
|
268
|
+
reason: `${info.kind === 'rate-limit' ? '订阅通道触发限流' : '订阅额度已用尽'}(预计 ${at} 恢复);该运行只能通过官方 CLI 修改文件,未自动切换 API Key。`,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const fileExists = async path => {
|
|
273
|
+
try { await stat(path); return true } catch { return false }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** MiniMax's non-secret auth-state.json: only `status` and `storeKind` leave this function. */
|
|
277
|
+
const readAuthState = async path => {
|
|
278
|
+
try {
|
|
279
|
+
const info = await stat(path)
|
|
280
|
+
if (!info.isFile() || info.size > 64 * 1024) return null
|
|
281
|
+
const value = JSON.parse(await readFile(path, 'utf8'))
|
|
282
|
+
return value && typeof value === 'object' ? { status: typeof value.status === 'string' ? value.status : null, storeKind: typeof value.storeKind === 'string' ? value.storeKind : null } : null
|
|
283
|
+
} catch { return null }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 开箱体检: install, version and login state for every registry tool. */
|
|
287
|
+
export async function toolHealthReport({ fresh = false, runner = defaultRunner } = {}) {
|
|
288
|
+
const probes = await probeAllTools({ fresh })
|
|
289
|
+
const report = await runHealthCheck(probes, { runner, home: homedir(), exists: fileExists, readAuthState })
|
|
290
|
+
healthCache.remember(report)
|
|
291
|
+
try { await routerStateStore().saveHealth(report) } catch { /* the cache still serves this process */ }
|
|
292
|
+
return report
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A recent report, re-running the cheap checks only when the cache expired. */
|
|
296
|
+
async function currentHealth() {
|
|
297
|
+
await hydrateHealth()
|
|
298
|
+
const report = healthCache.report()
|
|
299
|
+
if (report && Date.now() - report.checkedAt < HEALTH_CACHE_MS) return report
|
|
300
|
+
try { return await toolHealthReport() } catch { return report }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function loggedOutIds(report) {
|
|
304
|
+
return (report?.tools ?? []).filter(item => item.installed && healthCache.loginState(item.id)?.state === 'logged-out').map(item => item.id)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function routingPresetOf(config, requested) {
|
|
308
|
+
return normalizeRoutingPreset(text(requested) || valueOf(config, 'routingPreset', DEFAULT_ROUTING_PRESET))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function budgetSettings(config) {
|
|
312
|
+
return {
|
|
313
|
+
dailyLimitUsd: Math.max(0, finiteNumber(valueOf(config, 'dailyBudgetUsd', 0), 0)),
|
|
314
|
+
monthlyLimitUsd: Math.max(0, finiteNumber(valueOf(config, 'monthlyBudgetUsd', 0), 0)),
|
|
315
|
+
action: valueOf(config, 'overBudgetAction', 'downgrade') === 'pause' ? 'pause' : 'downgrade',
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function budgetFor(config, runs, estimateUsd) {
|
|
320
|
+
const settings = budgetSettings(config)
|
|
321
|
+
const spent = spending(runs)
|
|
322
|
+
return { ...budgetCheck({ estimateUsd, spent, ...settings }), spent, ...settings }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* API key / API path spend counts against budgets; an official CLI on its own
|
|
327
|
+
* subscription login (no API key injected or inherited) is reference-only.
|
|
328
|
+
*/
|
|
329
|
+
export function billingForResult(result) {
|
|
330
|
+
const toolId = result?.toolId ?? null
|
|
331
|
+
return billingOf(result, {
|
|
332
|
+
loginBilling: toolId ? healthCache.loginState(toolId)?.billing ?? null : null,
|
|
333
|
+
apiKeyPresent: toolId ? apiKeyEnvPresent(toolId) : false,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function routePricing(routes, item) {
|
|
338
|
+
return routes.find(route => route.provider === item?.provider && route.model === item?.model)?.pricing ?? null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Routes with user profiles and the gentle quality bias learned from ratings and reviews. */
|
|
342
|
+
async function routesWithLearning(ctx, config, signal, runs) {
|
|
343
|
+
const discovered = await discoverConfiguredRoutes(ctx, signal)
|
|
344
|
+
return applyQualityBiases(configuredRoutesWithProfiles(discovered, config), routeQualityBiases(runs))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Produce a route recommendation and work packages compatible with official Agent Teams. */
|
|
348
|
+
export async function createRoutePlan(ctx, task, config = {}, options = {}) {
|
|
349
|
+
const taskText = text(task)
|
|
350
|
+
if (!taskText) throw new Error('任务内容为空,请先描述任务。')
|
|
351
|
+
const mode = options.mode === 'team' ? 'team' : 'single'
|
|
352
|
+
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
353
|
+
const budgetUsd = Math.max(0, finiteNumber(options.budgetUsd, finiteNumber(configuredBudget, 0)))
|
|
354
|
+
const saved = await savedState()
|
|
355
|
+
const [availableRoutes, installed] = await Promise.all([
|
|
356
|
+
routesWithLearning(ctx, config, options.signal, saved.runs),
|
|
357
|
+
options.skipToolProbe === true
|
|
358
|
+
? Promise.resolve(Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
|
|
359
|
+
: installedToolIds(),
|
|
360
|
+
])
|
|
361
|
+
const readiness = options.skipToolProbe === true ? []
|
|
362
|
+
: await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
|
|
363
|
+
const executable = new Set(Array.isArray(options.runnableToolIds)
|
|
364
|
+
? options.runnableToolIds : readiness.filter(item => item.ready).map(item => item.id))
|
|
365
|
+
const loggedOut = options.skipToolProbe === true
|
|
366
|
+
? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
|
|
367
|
+
: loggedOutIds(await currentHealth())
|
|
368
|
+
const plan = createPlanFromRoutes(taskText, availableRoutes, {
|
|
369
|
+
mode, budgetUsd, installedToolIds: installed,
|
|
370
|
+
runnableToolIds: installed.filter(id => executable.has(id)),
|
|
371
|
+
preset: routingPresetOf(config, options.preset),
|
|
372
|
+
loggedOutToolIds: loggedOut,
|
|
373
|
+
})
|
|
374
|
+
return { ...plan, budgetStatus: budgetFor(config, saved.runs, plan.estimatedCost) }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** One bounded, independent call through the same official LLM service. */
|
|
378
|
+
export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_000, signal) {
|
|
379
|
+
const provider = text(route?.provider)
|
|
380
|
+
const model = text(route?.model)
|
|
381
|
+
const taskText = text(task)
|
|
382
|
+
if (!provider || !model) throw new Error('需要提供已配置的 provider 和 model。')
|
|
383
|
+
if (!taskText) throw new Error('任务内容为空,请先描述任务。')
|
|
384
|
+
throwIfAborted(signal)
|
|
385
|
+
const limit = boundedInteger(outputLimit, 12_000, 1, 50_000)
|
|
386
|
+
const controller = new AbortController()
|
|
387
|
+
const onAbort = () => controller.abort(signal.reason)
|
|
388
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
389
|
+
let answer = ''
|
|
390
|
+
let characterLimitReached = false
|
|
391
|
+
let finish = { kind: 'unknown' }
|
|
392
|
+
let usage = null
|
|
393
|
+
try {
|
|
394
|
+
const stream = ctx.llm.stream({
|
|
395
|
+
provider,
|
|
396
|
+
model,
|
|
397
|
+
messages: [{
|
|
398
|
+
role: 'user',
|
|
399
|
+
content: [{
|
|
400
|
+
type: 'text',
|
|
401
|
+
text: `You are an independent specialist consulted by another AI agent. Give a concise, evidence-oriented answer in the task's language.\n\nTask:\n${taskText}`,
|
|
402
|
+
}],
|
|
403
|
+
}],
|
|
404
|
+
maxTokens: Math.min(4_096, Math.max(256, Math.ceil(limit / 2))),
|
|
405
|
+
signal: controller.signal,
|
|
406
|
+
})
|
|
407
|
+
for await (const chunk of stream) {
|
|
408
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
409
|
+
const remaining = limit - answer.length
|
|
410
|
+
if (remaining > 0) answer += chunk.text.slice(0, remaining)
|
|
411
|
+
if (chunk.text.length > remaining) {
|
|
412
|
+
characterLimitReached = true
|
|
413
|
+
controller.abort(new Error('consultation output limit reached'))
|
|
414
|
+
break
|
|
415
|
+
}
|
|
416
|
+
} else if (chunk?.type === 'usage' && chunk.usage && typeof chunk.usage === 'object') {
|
|
417
|
+
usage = {
|
|
418
|
+
inputTokens: finiteNumber(chunk.usage.inputTokens, 0),
|
|
419
|
+
outputTokens: finiteNumber(chunk.usage.outputTokens, 0),
|
|
420
|
+
cacheReadTokens: finiteNumber(chunk.usage.cacheReadTokens, 0),
|
|
421
|
+
cacheWriteTokens: finiteNumber(chunk.usage.cacheWriteTokens, 0),
|
|
422
|
+
}
|
|
423
|
+
} else if (chunk?.type === 'finish') {
|
|
424
|
+
const reason = chunk.reason
|
|
425
|
+
finish = { kind: text(reason?.kind) || 'unknown' }
|
|
426
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
427
|
+
finish.error = text(reason?.failure?.message) || 'model request failed'
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} finally {
|
|
432
|
+
signal?.removeEventListener('abort', onAbort)
|
|
433
|
+
}
|
|
434
|
+
throwIfAborted(signal)
|
|
435
|
+
const tokenLimitReached = finish.kind === 'max-tokens'
|
|
436
|
+
const truncated = characterLimitReached || tokenLimitReached
|
|
437
|
+
if (!characterLimitReached && (finish.kind === 'error' || finish.kind === 'aborted')) {
|
|
438
|
+
return { ok: false, provider, model, answer, truncated, finish, error: finish.error }
|
|
439
|
+
}
|
|
440
|
+
if (!answer.trim()) {
|
|
441
|
+
return { ok: false, provider, model, answer, truncated, finish, error: 'model returned no text' }
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
ok: true, provider, model, answer, truncated, finish,
|
|
445
|
+
...(usage ? { usage } : {}),
|
|
446
|
+
...(tokenLimitReached
|
|
447
|
+
? { truncationReason: 'model-token-limit', notice: '模型达到本次调用的输出 token 上限,回答可能不完整。' }
|
|
448
|
+
: characterLimitReached
|
|
449
|
+
? { truncationReason: 'output-character-limit', notice: '回答达到字符上限,后续内容已截断。' }
|
|
450
|
+
: {}),
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function credentialsForRoute(ctx, route) {
|
|
455
|
+
const provider = text(route?.provider)
|
|
456
|
+
if (!provider) return null
|
|
457
|
+
const readers = [ctx?.credentials?.getApiKey, ctx?.llm?.getProviderApiKey]
|
|
458
|
+
for (const read of readers) {
|
|
459
|
+
if (typeof read !== 'function') continue
|
|
460
|
+
try {
|
|
461
|
+
const value = await read(provider)
|
|
462
|
+
const apiKey = typeof value === 'string' ? value : value?.apiKey ?? value?.key
|
|
463
|
+
if (typeof apiKey === 'string' && apiKey.trim() && !apiKey.includes('\0') && apiKey.length <= 4_096) {
|
|
464
|
+
return { apiKey }
|
|
465
|
+
}
|
|
466
|
+
} catch { /* this host build does not expose provider keys; the CLI login session remains available */ }
|
|
467
|
+
}
|
|
468
|
+
return null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function routeQuality(route) {
|
|
472
|
+
if (!route) return null
|
|
473
|
+
const base = Number.isFinite(route.quality) ? route.quality : modelMetadata(route.model)?.quality
|
|
474
|
+
return Number.isFinite(base) ? base : null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The strongest configured route by known quality, used as the reviewer. */
|
|
478
|
+
export function reviewerRoute(routes) {
|
|
479
|
+
return routes
|
|
480
|
+
.map(route => ({ route, quality: routeQuality(route) }))
|
|
481
|
+
.filter(item => item.quality !== null)
|
|
482
|
+
.sort((left, right) => right.quality - left.quality || routeKey(left.route).localeCompare(routeKey(right.route)))[0]?.route ?? null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function reviewScore(answer) {
|
|
486
|
+
const match = /"score"\s*:\s*([1-5])/u.exec(answer) ?? /(?:评分|分数|score)\s*[::]?\s*([1-5])\b/iu.exec(answer)
|
|
487
|
+
return match ? Number(match[1]) : null
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 质量回路: a stronger configured model spot-checks answers from cheaper
|
|
492
|
+
* routes. `sample` reviews a random share; `always` reviews every cheaper answer.
|
|
493
|
+
*/
|
|
494
|
+
export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random } = {}) {
|
|
495
|
+
const mode = valueOf(config, 'reviewMode', 'off')
|
|
496
|
+
if (mode !== 'sample' && mode !== 'always') return []
|
|
497
|
+
const rate = Math.min(1, Math.max(0, finiteNumber(valueOf(config, 'reviewSampleRate', 0.2), 0.2)))
|
|
498
|
+
const reviewer = reviewerRoute(routes)
|
|
499
|
+
if (!reviewer) return []
|
|
500
|
+
const reviewerQuality = routeQuality(reviewer)
|
|
501
|
+
const reviews = []
|
|
502
|
+
for (const item of run.packages) {
|
|
503
|
+
if (!item.ok || !text(item.answer)) continue
|
|
504
|
+
if (item.provider === reviewer.provider && item.model === reviewer.model) continue
|
|
505
|
+
const own = routeQuality(routes.find(route => route.provider === item.provider && route.model === item.model))
|
|
506
|
+
if (own !== null && own >= reviewerQuality - 0.02) continue
|
|
507
|
+
if (mode === 'sample' && random() >= rate) continue
|
|
508
|
+
const prompt = [
|
|
509
|
+
'请作为更强的审阅模型,抽查下面这个由较经济模型完成的工作包结果。',
|
|
510
|
+
'只输出一行 JSON:{"score": 1-5 的整数, "summary": "不超过 80 字的主要问题或肯定"}。5 表示可直接采用,1 表示错误或不可用。',
|
|
511
|
+
`总任务:\n${run.task.slice(0, 6_000)}`,
|
|
512
|
+
`工作包:${item.name}\n${item.objective.slice(0, 1_500)}`,
|
|
513
|
+
`待审结果:\n${item.answer.slice(0, 3_500)}`,
|
|
514
|
+
].join('\n\n')
|
|
515
|
+
try {
|
|
516
|
+
const result = await consultConfiguredModel(ctx, reviewer, prompt, 1_200, signal)
|
|
517
|
+
const score = result.ok ? reviewScore(result.answer) : null
|
|
518
|
+
let summary = ''
|
|
519
|
+
try { summary = text(JSON.parse(/\{[\s\S]*\}/u.exec(result.answer)?.[0] ?? '{}').summary) } catch { /* free-form review */ }
|
|
520
|
+
item.review = {
|
|
521
|
+
provider: reviewer.provider, model: reviewer.model, score,
|
|
522
|
+
summary: (summary || text(result.answer) || text(result.error)).slice(0, 300), at: Date.now(),
|
|
523
|
+
}
|
|
524
|
+
const cost = actualCost(result, reviewer.pricing ?? null)
|
|
525
|
+
const entry = { packageId: item.id, provider: reviewer.provider, model: reviewer.model, ran: true, finishedAt: Date.now(), usage: result.usage ?? null, ...cost, billing: 'api', referenceCostUsd: null }
|
|
526
|
+
run.reviews.push(entry)
|
|
527
|
+
reviews.push({ ...entry, score })
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (signal?.aborted) throw error
|
|
530
|
+
item.review = { provider: reviewer.provider, model: reviewer.model, score: null, summary: `审阅失败:${errorText(error).slice(0, 200)}`, at: Date.now() }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return reviews
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function executionHooks(ctx, options) {
|
|
537
|
+
return {
|
|
538
|
+
skipOfficial: options.skipOfficial ?? (request => healthCache.skipReason(request)),
|
|
539
|
+
onPackage: async result => {
|
|
540
|
+
// A CLI that reports "not logged in" is marked; with a subscription the router then asks instead of using the API key.
|
|
541
|
+
const failed = result?.toolId && (result.fallback?.loginRequired ? result.fallback.error : result.pause?.loginRequired && result.pause.kind !== 'subscription-login' ? result.pause.detail : null)
|
|
542
|
+
if (failed) {
|
|
543
|
+
const at = Date.now()
|
|
544
|
+
healthCache.markLoggedOut(result.toolId, failed, at)
|
|
545
|
+
try { await routerStateStore().saveAuthFailure(result.toolId, { at, detail: failed }) } catch { /* the cache still serves this process */ }
|
|
546
|
+
} else if (result?.ok && result.channel === 'official-cli' && result.toolId && healthCache.loginState(result.toolId)?.source === 'runtime-auth') {
|
|
547
|
+
healthCache.clearLoggedOut(result.toolId)
|
|
548
|
+
try { await routerStateStore().saveAuthFailure(result.toolId, null) } catch { /* the cache still serves this process */ }
|
|
549
|
+
}
|
|
550
|
+
if (typeof options.onPackage === 'function') await options.onPackage(result)
|
|
551
|
+
},
|
|
552
|
+
credentialsFor: route => credentialsForRoute(ctx, route),
|
|
553
|
+
runVerified: options.runVerified ?? (request => runOfficialTool({
|
|
554
|
+
...request, sandbox: ctx.sandbox, mode: 'read-only',
|
|
555
|
+
})),
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The plan and budget check model_router_execute would use, without running
|
|
561
|
+
* anything: one explicit model or routing, with the automatic economy
|
|
562
|
+
* downgrade when the budget would be exceeded. Shared by the workbench preview.
|
|
563
|
+
*/
|
|
564
|
+
export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
565
|
+
const taskText = text(task)
|
|
566
|
+
// Validate before planning or any paid call: the whole task must fit one CLI/API prompt.
|
|
567
|
+
const problem = taskTextProblem(taskText)
|
|
568
|
+
if (problem) throw new Error(problem)
|
|
569
|
+
const direct = text(options.provider) || text(options.model)
|
|
570
|
+
if (direct && (!text(options.provider) || !text(options.model))) throw new Error('指定模型需要同时提供 provider 和 model。')
|
|
571
|
+
const saved = await savedState()
|
|
572
|
+
const routes = await routesWithLearning(ctx, config, options.signal, saved.runs)
|
|
573
|
+
if (direct && !routes.some(route => route.provider === text(options.provider) && route.model === text(options.model))) {
|
|
574
|
+
throw new Error(`模型 ${text(options.provider)}/${text(options.model)} 不在 Harness 模型目录中;请在模型页添加后重试,或改用自动路由。`)
|
|
575
|
+
}
|
|
576
|
+
const installed = options.skipToolProbe === true
|
|
577
|
+
? (Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
|
|
578
|
+
: await installedToolIds()
|
|
579
|
+
const readiness = options.skipToolProbe === true ? []
|
|
580
|
+
: await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
|
|
581
|
+
const runnableToolIds = Array.isArray(options.runnableToolIds)
|
|
582
|
+
? options.runnableToolIds
|
|
583
|
+
: readiness.filter(item => item.ready).map(item => item.id)
|
|
584
|
+
const loggedOut = options.skipToolProbe === true
|
|
585
|
+
? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
|
|
586
|
+
: loggedOutIds(await currentHealth())
|
|
587
|
+
const preset = routingPresetOf(config, options.preset)
|
|
588
|
+
const planWith = (presetId, budgetUsd) => direct
|
|
589
|
+
? createPlanFromRoutes(taskText, routes, {
|
|
590
|
+
mode: 'direct', directProvider: options.provider, directModel: options.model,
|
|
591
|
+
installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
592
|
+
})
|
|
593
|
+
: createPlanFromRoutes(taskText, routes, {
|
|
594
|
+
mode: options.planMode === 'team' ? 'team' : 'single',
|
|
595
|
+
budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
596
|
+
})
|
|
597
|
+
let plan = planWith(preset, options.budgetUsd)
|
|
598
|
+
let budget = budgetFor(config, saved.runs, plan.estimatedCost)
|
|
599
|
+
if (budget.exceeded && budget.action === 'downgrade' && !direct) {
|
|
600
|
+
const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
|
|
601
|
+
const recheck = budgetFor(config, saved.runs, cheaper.estimatedCost)
|
|
602
|
+
if (!recheck.exceeded) {
|
|
603
|
+
budget = { ...recheck, downgraded: true, downgradedFrom: preset,
|
|
604
|
+
message: `原方案${budget.message} 已自动降级为“省钱优先”方案(预估 ${cheaper.estimatedCost === null ? '价格待配置' : formatUsd(cheaper.estimatedCost)})。` }
|
|
605
|
+
plan = cheaper
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { taskText, direct: Boolean(direct), plan, budget, routes, preset }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Route, or honor one explicit model, then run each package through its adapter. */
|
|
612
|
+
export async function executeConfiguredAssignment(ctx, task, config = {}, options = {}) {
|
|
613
|
+
const { taskText, direct, plan, budget, routes, preset } = await planAssignment(ctx, task, config, options)
|
|
614
|
+
if (budget.exceeded && options.confirmOverBudget !== true) {
|
|
615
|
+
return {
|
|
616
|
+
plan, execution: null, paused: true,
|
|
617
|
+
budget: { ...budget, paused: true,
|
|
618
|
+
message: `${budget.message} 已暂停执行:${direct ? '指定模型无法自动降级。' : budget.action === 'pause' ? '设置为超预算时暂停。' : '降级后仍超出预算。'}请向用户确认后,以 confirmOverBudget: true 重新调用。` },
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const finished = []
|
|
622
|
+
const hooks = executionHooks(ctx, { ...options, onPackage: result => { finished.push(result) } })
|
|
623
|
+
const billing = await billingContext(config, options)
|
|
624
|
+
const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
|
|
625
|
+
const startedAt = Date.now()
|
|
626
|
+
let execution
|
|
627
|
+
try {
|
|
628
|
+
execution = await executeAssignmentPlan({
|
|
629
|
+
plan,
|
|
630
|
+
task: taskText,
|
|
631
|
+
routes: plan.availableRoutes,
|
|
632
|
+
workspace: options.workspace,
|
|
633
|
+
signal: options.signal,
|
|
634
|
+
...hooks,
|
|
635
|
+
billing,
|
|
636
|
+
apiFallback: async ({ route, task: packageTask, signal }) => consultConfiguredModel(ctx, route, packageTask, limit, signal),
|
|
637
|
+
})
|
|
638
|
+
} catch (error) {
|
|
639
|
+
// Steps that already ran (and may have been paid for) are always recorded with their spend.
|
|
640
|
+
const message = String(error?.message ?? error)
|
|
641
|
+
const partial = { status: options.signal?.aborted ? 'cancelled' : 'failed', packages: finished, aggregate: '', error: message }
|
|
642
|
+
const run = buildRunRecord({
|
|
643
|
+
id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution: partial,
|
|
644
|
+
preset: plan.preset ?? preset, workspace: options.workspace ?? '',
|
|
645
|
+
pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
|
|
646
|
+
budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
|
|
647
|
+
})
|
|
648
|
+
run.error = message.slice(0, 2_000)
|
|
649
|
+
let stored = true
|
|
650
|
+
try { await routerStateStore().appendRun(run) } catch { stored = false }
|
|
651
|
+
const wrapped = new Error(`${message}${finished.length ? `(已完成 ${finished.length} 个步骤,${stored ? `已记录到运行 ${run.id},费用计入预算` : '但运行记录保存失败'})` : ''}`)
|
|
652
|
+
wrapped.runId = stored ? run.id : null
|
|
653
|
+
wrapped.cause = error
|
|
654
|
+
throw wrapped
|
|
655
|
+
}
|
|
656
|
+
const run = buildRunRecord({
|
|
657
|
+
id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution,
|
|
658
|
+
preset: plan.preset ?? preset, workspace: options.workspace ?? '',
|
|
659
|
+
pricingFor: item => routePricing(routes, item),
|
|
660
|
+
billingFor: billingForResult,
|
|
661
|
+
budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
|
|
662
|
+
})
|
|
663
|
+
const reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random })
|
|
664
|
+
let stored = true
|
|
665
|
+
try { await routerStateStore().appendRun(run) } catch { stored = false }
|
|
666
|
+
const awaiting = pausedSteps(execution)
|
|
667
|
+
return { plan, execution, budget, runId: run.id, run, reviews, stored, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function storedRun(runId) {
|
|
671
|
+
const saved = await savedState()
|
|
672
|
+
const run = saved.runs.find(item => item.id === text(runId))
|
|
673
|
+
if (!run) throw new Error(`未找到运行记录 ${text(runId)}`)
|
|
674
|
+
return { run, saved }
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Re-run one failed (or, with a new route, any) step and its unfinished downstream steps. */
|
|
678
|
+
/**
|
|
679
|
+
* Whether a recorded run can be re-run and whether that rerun edits files.
|
|
680
|
+
* Editable team reruns continue on a fresh worktree seeded with the earlier,
|
|
681
|
+
* never integrated worktree; they need a recorded base commit and an
|
|
682
|
+
* incomplete (not integrated) run.
|
|
683
|
+
*/
|
|
684
|
+
export function rerunSupport(run) {
|
|
685
|
+
if (!run) return { ok: false, reason: '未找到运行记录。' }
|
|
686
|
+
if (run.kind === 'tool') {
|
|
687
|
+
const toolId = run.toolRun?.toolId ?? run.packages?.[0]?.toolId
|
|
688
|
+
if (!toolId) return { ok: false, reason: '该单次调用记录缺少官方工具 ID,无法重跑;请直接再次调用 model_router_tool_run。' }
|
|
689
|
+
return { ok: true, kind: 'tool', writes: run.executionMode === 'workspace-write' }
|
|
690
|
+
}
|
|
691
|
+
if (run.kind === 'team' && run.executionMode === 'workspace-write') {
|
|
692
|
+
if (!['incomplete', 'cancelled'].includes(run.status)) {
|
|
693
|
+
return { ok: false, reason: run.status === 'integration-pending'
|
|
694
|
+
? '该可编辑团队运行的改动尚待人工整合(独立工作区已保留);请先核对并整合,再重新执行整个团队任务。'
|
|
695
|
+
: '该可编辑团队运行的改动已整合到工作区;单步重跑会重复套用改动。如需重做请重新调用 model_router_team_execute。' }
|
|
696
|
+
}
|
|
697
|
+
if (!run.isolatedWorkspace || !run.baseCommit) {
|
|
698
|
+
return { ok: false, reason: '该可编辑团队运行没有记录独立工作区或 Git 基线(旧版本记录),无法安全续跑;请重新调用 model_router_team_execute。' }
|
|
699
|
+
}
|
|
700
|
+
return { ok: true, kind: 'team-write', writes: true }
|
|
701
|
+
}
|
|
702
|
+
return { ok: true, kind: run.kind === 'team' ? 'team' : 'assign', writes: false }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
|
|
706
|
+
const { run, saved } = await storedRun(runId)
|
|
707
|
+
const target = run.packages.find(item => item.id === text(packageId))
|
|
708
|
+
if (!target) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
|
|
709
|
+
const choice = text(subscriptionChoice) || null
|
|
710
|
+
if (choice && !['api', 'subscription', 'cancel'].includes(choice)) throw new Error('subscriptionChoice 只能是 api、subscription 或 cancel')
|
|
711
|
+
if (choice && !target.paused) throw new Error('该步骤没有在等待订阅失败的确认;直接重跑即可。')
|
|
712
|
+
if (choice === 'cancel') return { run: await cancelPausedStep(run.id, target.id), execution: null, budget: null, cancelled: true }
|
|
713
|
+
const routes = await routesWithLearning(ctx, config, signal, saved.runs)
|
|
714
|
+
let override = null
|
|
715
|
+
if (text(provider) || text(model)) {
|
|
716
|
+
if (!text(provider) || !text(model)) throw new Error('改派需要同时提供 provider 和 model')
|
|
717
|
+
if (valueOf(config, 'allowManualReassign', true) === false) throw new Error('设置中已关闭手动改派。')
|
|
718
|
+
if (!routes.some(route => route.provider === text(provider) && route.model === text(model))) {
|
|
719
|
+
throw new Error(`路线 ${text(provider)}/${text(model)} 不在 Harness 模型目录中;请在官方“模型”页添加后重试,或选择列表中的其他路线。`)
|
|
720
|
+
}
|
|
721
|
+
override = { provider: text(provider), model: text(model) }
|
|
722
|
+
} else if (target.ok) {
|
|
723
|
+
throw new Error('该步骤已成功;如需换模型重做,请指定改派的 provider/model。')
|
|
724
|
+
}
|
|
725
|
+
if (choice && run.kind !== 'assign' && run.kind !== undefined) throw new Error('只有路由执行的步骤会因订阅失败暂停。')
|
|
726
|
+
const support = rerunSupport(run)
|
|
727
|
+
if (!support.ok) throw new Error(support.reason)
|
|
728
|
+
if (support.kind === 'tool') {
|
|
729
|
+
if (override) throw new Error('单次调用的重跑沿用原工具和模型;如需换模型请直接调用 model_router_tool_run。')
|
|
730
|
+
return rerunToolRun(ctx, config, { run, confirmOverBudget, confirmWrite, workspace, root, signal, options })
|
|
731
|
+
}
|
|
732
|
+
if (support.kind === 'team-write' && target.ok) {
|
|
733
|
+
throw new Error('可编辑团队运行只能续跑失败或未完成的步骤:已成功步骤的改动已在原独立工作区中,重跑会重复套用。如需重做请重新调用 model_router_team_execute。')
|
|
734
|
+
}
|
|
735
|
+
if (support.writes && confirmWrite !== true) {
|
|
736
|
+
return { paused: true, needsConfirmation: ['workspace-write'], run,
|
|
737
|
+
budget: null, message: '续跑可编辑团队步骤会在新的独立 Git 工作树中套用之前的改动并修改文件,需要先确认。' }
|
|
738
|
+
}
|
|
739
|
+
const sameRoute = !override || (override.provider === target.recommendedProvider && override.model === target.recommendedModel)
|
|
740
|
+
const budget = budgetFor(config, saved.runs, sameRoute ? target.estimatedCost : null)
|
|
741
|
+
if (budget.exceeded && confirmOverBudget !== true) {
|
|
742
|
+
return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
|
|
743
|
+
}
|
|
744
|
+
const cwd = text(workspace) || run.workspace
|
|
745
|
+
if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
|
|
746
|
+
if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root: text(root) || cwd, signal, options, config })
|
|
747
|
+
const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
|
|
748
|
+
const execution = await rerunAssignmentPackage({
|
|
749
|
+
task: run.task,
|
|
750
|
+
packages: run.packages.map(item => ({
|
|
751
|
+
id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
|
|
752
|
+
recommendedProvider: item.provider ?? item.recommendedProvider, recommendedModel: item.model ?? item.recommendedModel,
|
|
753
|
+
})),
|
|
754
|
+
previous: storedResults(run),
|
|
755
|
+
packageId: target.id,
|
|
756
|
+
routingBypassed: run.routingBypassed,
|
|
757
|
+
routes,
|
|
758
|
+
override,
|
|
759
|
+
workspace: cwd,
|
|
760
|
+
signal,
|
|
761
|
+
...(choice ? { subscriptionChoice: choice } : {}),
|
|
762
|
+
...executionHooks(ctx, options),
|
|
763
|
+
billing: await billingContext(config, options),
|
|
764
|
+
apiFallback: async ({ route, task: packageTask, signal: callSignal }) => consultConfiguredModel(ctx, route, packageTask, limit, callSignal),
|
|
765
|
+
})
|
|
766
|
+
const updated = await routerStateStore().updateRun(run.id, current => {
|
|
767
|
+
mergeRerun(current, execution, { rerunIds: execution.ranIds, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
|
|
768
|
+
})
|
|
769
|
+
const awaiting = pausedSteps(execution)
|
|
770
|
+
return { run: updated, execution, budget, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The user cancelled a paused step: it and the steps waiting on it stop. */
|
|
774
|
+
async function cancelPausedStep(runId, packageId) {
|
|
775
|
+
return routerStateStore().updateRun(runId, current => {
|
|
776
|
+
const waiting = new Set(downstreamPackageIds(current.packages, packageId))
|
|
777
|
+
for (const item of current.packages) {
|
|
778
|
+
if (item.id === packageId) {
|
|
779
|
+
Object.assign(item, { status: 'cancelled', cancelled: true, paused: false, ok: false,
|
|
780
|
+
error: `已取消:${item.pause?.reason ?? item.error ?? ''}`.slice(0, 2_000) })
|
|
781
|
+
delete item.pause
|
|
782
|
+
} else if (waiting.has(item.id) && !item.ok) {
|
|
783
|
+
Object.assign(item, { status: 'blocked', waiting: false, blocked: true, error: `上游步骤 ${packageId} 已取消。` })
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
current.status = current.packages.some(item => item.paused) ? 'paused'
|
|
787
|
+
: current.packages.some(item => item.ok) ? 'partial' : 'cancelled'
|
|
788
|
+
current.finishedAt = Date.now()
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Stored team packages in plan shape; `override` reassigns the retried package. */
|
|
793
|
+
function teamPlanFromRun(run, ids, override, targetId) {
|
|
794
|
+
const workPackages = run.packages.filter(item => ids.includes(item.id)).map(item => ({
|
|
795
|
+
id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn ?? [],
|
|
796
|
+
type: item.type ?? 'general', purpose: item.purpose ?? 'execution',
|
|
797
|
+
verificationChecklist: item.verificationChecklist ?? [],
|
|
798
|
+
recommendedProvider: item.id === targetId && override ? override.provider : item.recommendedProvider,
|
|
799
|
+
recommendedModel: item.id === targetId && override ? override.model : item.recommendedModel,
|
|
800
|
+
estimatedCost: item.estimatedCost ?? null,
|
|
801
|
+
}))
|
|
802
|
+
return { mode: 'team', team: { workPackages } }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Read-only team retry: the failed step plus downstream steps that had not
|
|
807
|
+
* succeeded run through the same signed runner; earlier answers are context.
|
|
808
|
+
*/
|
|
809
|
+
async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root, signal, options, config = {} }) {
|
|
810
|
+
const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
811
|
+
const downstream = downstreamPackageIds(run.packages, target.id)
|
|
812
|
+
.filter(id => !run.packages.find(item => item.id === id)?.ok)
|
|
813
|
+
const ids = [target.id, ...downstream]
|
|
814
|
+
const plan = teamPlanFromRun(run, ids, override, target.id)
|
|
815
|
+
const cliModels = Object.fromEntries(Object.entries(run.cliModels ?? {}).filter(([key]) => ids.includes(key) && !(override && key === target.id)))
|
|
816
|
+
const previous = run.packages.filter(item => item.ok && !ids.includes(item.id))
|
|
817
|
+
.map(item => ({ id: item.id, name: item.name, finalText: item.answer }))
|
|
818
|
+
const runTeam = options.runTeam ?? runOfficialTeam
|
|
819
|
+
const installed = Array.isArray(options.installedToolIds) ? options.installedToolIds : await installedToolIds()
|
|
820
|
+
const execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode,
|
|
821
|
+
installedIds: installed, cliModels, signal, sandbox: ctx.sandbox, previous, onlyIds: ids,
|
|
822
|
+
...(mode === 'workspace-write' ? { seedFrom: { workspace: run.isolatedWorkspace, baseCommit: run.baseCommit } } : {}),
|
|
823
|
+
...(options.runtime ? { runtime: options.runtime } : {}) })
|
|
824
|
+
const converted = teamExecutionResults(plan, execution)
|
|
825
|
+
if (override) {
|
|
826
|
+
const entry = converted.packages.find(item => item.id === target.id)
|
|
827
|
+
if (entry) entry.reassigned = true
|
|
828
|
+
}
|
|
829
|
+
const quotaNotes = []
|
|
830
|
+
for (const result of Array.isArray(execution?.results) ? execution.results : []) {
|
|
831
|
+
if (result?.status === 'succeeded' || !result?.toolId) continue
|
|
832
|
+
const note = await noteCliQuota(config, result.toolId, `${result.error ?? ''}\n${result.outputTail ?? ''}`)
|
|
833
|
+
if (note) quotaNotes.push([result.id, note])
|
|
834
|
+
}
|
|
835
|
+
const updated = await routerStateStore().updateRun(run.id, current => {
|
|
836
|
+
mergeRerun(current, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
|
|
837
|
+
for (const [id, note] of quotaNotes) {
|
|
838
|
+
const stored = current.packages.find(item => item.id === id)
|
|
839
|
+
if (stored) stored.billingSwitch = note
|
|
840
|
+
}
|
|
841
|
+
current.status = execution?.status ?? current.status
|
|
842
|
+
if (mode === 'workspace-write' && execution?.workspace && execution.status !== 'blocked') {
|
|
843
|
+
current.isolatedWorkspace = String(execution.workspace)
|
|
844
|
+
if (typeof execution.baseCommit === 'string') current.baseCommit = execution.baseCommit
|
|
845
|
+
if (execution.integration) current.integration = { ignoredArtifacts: execution.integration.ignoredArtifacts ?? 0 }
|
|
846
|
+
}
|
|
847
|
+
if (override) {
|
|
848
|
+
const stored = current.packages.find(item => item.id === target.id)
|
|
849
|
+
if (stored) { stored.recommendedProvider = override.provider; stored.recommendedModel = override.model }
|
|
850
|
+
}
|
|
851
|
+
})
|
|
852
|
+
return { run: updated, execution: { ...converted, ranIds: ids, raw: execution }, budget }
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Repeat a recorded model_router_tool_run call as a new run linked by
|
|
857
|
+
* `rerunOf`. An editable call starts from a fresh worktree of the current
|
|
858
|
+
* checkout (the earlier, failed attempt is left in its own worktree).
|
|
859
|
+
*/
|
|
860
|
+
async function rerunToolRun(ctx, config, { run, confirmOverBudget, confirmWrite, workspace, root, signal, options = {} }) {
|
|
861
|
+
if (run.packages?.[0]?.ok) throw new Error('该单次调用已成功;如需重做请直接再次调用 model_router_tool_run。')
|
|
862
|
+
const meta = run.toolRun ?? { toolId: run.packages?.[0]?.toolId, provider: null, model: null, cliModel: null }
|
|
863
|
+
const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
864
|
+
const planned = await (options.planToolRun ?? planToolRun)(ctx, config, {
|
|
865
|
+
tool: meta.toolId, task: run.task, mode, provider: meta.provider ?? undefined, model: meta.model ?? undefined, cliModel: meta.cliModel ?? undefined,
|
|
866
|
+
}, { signal })
|
|
867
|
+
if (planned.budget.exceeded && confirmOverBudget !== true) {
|
|
868
|
+
return { paused: true, run, budget: { ...planned.budget, paused: true, message: `${planned.budget.message} 已暂停重跑,请确认后再试。` } }
|
|
869
|
+
}
|
|
870
|
+
if (mode === 'workspace-write' && confirmWrite !== true) {
|
|
871
|
+
return { paused: true, needsConfirmation: ['workspace-write'], run, budget: planned.budget,
|
|
872
|
+
message: '重跑可编辑单次调用会在新的独立 Git 工作树中修改文件并整合回工作区,需要先确认。' }
|
|
873
|
+
}
|
|
874
|
+
const cwd = text(workspace) || run.workspace
|
|
875
|
+
if (!cwd || !(await fileExists(cwd))) throw new Error('原运行的工作区不可用,无法重跑;请在原工作区的会话中重试。')
|
|
876
|
+
const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root: text(root) || cwd, signal, rerunOf: run.id,
|
|
877
|
+
...(options.runTask ? { runTask: options.runTask } : {}) })
|
|
878
|
+
const execution = { status: result.status, packages: recorded?.packages ?? [], ranIds: ['direct'], raw: result }
|
|
879
|
+
return { run: recorded ?? run, execution, budget: planned.budget, rerunOf: run.id, newRunId: recorded?.id ?? null }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Ledger record for model_router_team_execute; storage failures never fail the run. */
|
|
883
|
+
/** Attach a recorded quota hit to the stored package of a CLI-only run. */
|
|
884
|
+
async function annotateCliQuota(run, config, failures) {
|
|
885
|
+
for (const failure of failures) {
|
|
886
|
+
const note = await noteCliQuota(config, failure.toolId, failure.text)
|
|
887
|
+
const stored = note ? run.packages.find(item => item.id === failure.id) : null
|
|
888
|
+
if (stored) stored.billingSwitch = note
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
export async function recordTeamRun({ task, plan, execution, mode, workspace, routes, cliModels, budget, startedAt, preset, config = {} }) {
|
|
893
|
+
const run = buildTeamRunRecord({
|
|
894
|
+
id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task, plan, execution, mode, workspace,
|
|
895
|
+
preset: plan?.preset ?? preset ?? DEFAULT_ROUTING_PRESET,
|
|
896
|
+
pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
|
|
897
|
+
budget: budget ? { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: false, confirmed: budget.exceeded ? true : undefined } : null,
|
|
898
|
+
})
|
|
899
|
+
const bindings = Object.fromEntries(Object.entries(cliModels ?? {}).filter(([, value]) => typeof value === 'string' && value))
|
|
900
|
+
if (Object.keys(bindings).length) run.cliModels = bindings
|
|
901
|
+
await annotateCliQuota(run, config, (Array.isArray(execution?.results) ? execution.results : [])
|
|
902
|
+
.filter(result => result?.status !== 'succeeded' && result?.toolId)
|
|
903
|
+
.map(result => ({ id: result.id, toolId: result.toolId, text: `${result.error ?? ''}\n${result.outputTail ?? ''}` })))
|
|
904
|
+
try { await routerStateStore().appendRun(run); return run } catch { return null }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Validate a model_router_tool_run request and estimate it. The estimate uses
|
|
909
|
+
* the configured price of the named provider/model route; without a route (CLI
|
|
910
|
+
* default model) the cost is unknown and only already-exceeded budgets pause.
|
|
911
|
+
*/
|
|
912
|
+
export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
|
|
913
|
+
const toolId = text(args.tool)
|
|
914
|
+
const tool = getOfficialTool(toolId)
|
|
915
|
+
if (!tool) throw new Error(`未知的官方工具 ${toolId || '(空)'};可用 ID 见 model_router_tools。`)
|
|
916
|
+
const taskText = text(args.task)
|
|
917
|
+
const problem = taskTextProblem(taskText)
|
|
918
|
+
if (problem) throw new Error(problem)
|
|
919
|
+
const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
920
|
+
let modelId = null
|
|
921
|
+
let route = null
|
|
922
|
+
let routes = []
|
|
923
|
+
if (text(args.provider) || text(args.model)) {
|
|
924
|
+
if (!text(args.provider) || !text(args.model)) throw new Error('provider 和 model 必须同时提供')
|
|
925
|
+
if (toolForProvider(args.provider)?.id !== toolId) throw new Error('所选模型供应商与官方 CLI 工具不匹配')
|
|
926
|
+
routes = configuredRoutesWithProfiles(await discoverConfiguredRoutes(ctx, signal), config)
|
|
927
|
+
route = routes.find(item => item.provider === text(args.provider) && item.model === text(args.model)) ?? null
|
|
928
|
+
if (!route) throw new Error('所选 provider/model 不在官方模型目录中')
|
|
929
|
+
modelId = route.cliModel && toolId !== 'zcode'
|
|
930
|
+
? route.cliModel
|
|
931
|
+
: toolId === 'claude-code' || toolId === 'codex' ? route.model : null
|
|
932
|
+
}
|
|
933
|
+
if (text(args.cliModel)) {
|
|
934
|
+
if (!route) throw new Error('cliModel 需要同时提供已配置的 provider 和 model 路线')
|
|
935
|
+
if (toolId === 'zcode') throw new Error('ZCode 3.14.3 不支持在单次调用中指定 CLI 模型')
|
|
936
|
+
modelId = text(args.cliModel)
|
|
937
|
+
}
|
|
938
|
+
const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
|
|
939
|
+
const budget = budgetFor(config, (await savedState()).runs, Number.isFinite(estimate) ? estimate : null)
|
|
940
|
+
return { toolId, tool, taskText, mode, modelId, route, routes, estimate: Number.isFinite(estimate) ? estimate : null, budget,
|
|
941
|
+
provider: route?.provider ?? '', model: route?.model ?? '', cliModel: text(args.cliModel) || null }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Run a planned tool call and record it; `rerunOf` links a rerun to the original run. */
|
|
945
|
+
async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, runTask = runOfficialTask }) {
|
|
946
|
+
const startedAt = Date.now()
|
|
947
|
+
const result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
|
|
948
|
+
mode: planned.mode, signal, sandbox: ctx.sandbox })
|
|
949
|
+
const recorded = result.status === 'unsupported' ? null : await recordToolRun({
|
|
950
|
+
toolId: planned.toolId, task: planned.taskText, provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
|
|
951
|
+
mode: planned.mode, workspace: cwd, result, startedAt, routes: planned.routes, config,
|
|
952
|
+
estimatedCost: planned.estimate, budget: planned.budget, rerunOf,
|
|
953
|
+
})
|
|
954
|
+
return { result, recorded }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Ledger record for model_router_tool_run. */
|
|
958
|
+
export async function recordToolRun({ toolId, task, provider, model, cliModel = null, mode, workspace, result, routes = [], startedAt, config = {}, estimatedCost = null, budget = null, rerunOf = null }) {
|
|
959
|
+
const run = buildToolRunRecord({
|
|
960
|
+
id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task, workspace,
|
|
961
|
+
toolId, toolLabel: getOfficialTool(toolId)?.label ?? toolId, provider, model, cliModel, mode, result, estimatedCost, rerunOf,
|
|
962
|
+
pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
|
|
963
|
+
budget: budget ? { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: false, confirmed: budget.exceeded ? true : undefined } : null,
|
|
964
|
+
})
|
|
965
|
+
if (result && result.status !== 'succeeded') {
|
|
966
|
+
await annotateCliQuota(run, config, [{ id: 'direct', toolId, text: `${result.error ?? result.reason ?? ''}\n${result.outputTail ?? ''}` }])
|
|
967
|
+
}
|
|
968
|
+
try { await routerStateStore().appendRun(run); return run } catch { return null }
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Store a user rating (+1 useful, -1 not useful, 0 clears) for one result. */
|
|
972
|
+
export async function rateRecordedResult({ runId, packageId, rating } = {}) {
|
|
973
|
+
const value = rating === 'up' || rating === 1 ? 1 : rating === 'down' || rating === -1 ? -1 : rating === 'clear' || rating === 0 ? null : undefined
|
|
974
|
+
if (value === undefined) throw new Error('rating 只能是 up、down 或 clear')
|
|
975
|
+
return routerStateStore().updateRun(text(runId), current => {
|
|
976
|
+
const item = current.packages.find(entry => entry.id === text(packageId))
|
|
977
|
+
if (!item) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
|
|
978
|
+
item.rating = value
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
|
|
983
|
+
export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
|
|
984
|
+
const saved = await savedState()
|
|
985
|
+
const spent = spending(saved.runs)
|
|
986
|
+
const settings = budgetSettings(config)
|
|
987
|
+
return {
|
|
988
|
+
runs: saved.runs.slice(-limit).reverse().map(run => ({
|
|
989
|
+
...run,
|
|
990
|
+
task: run.task.slice(0, 2_000),
|
|
991
|
+
packages: run.packages.map(item => ({ ...item, answer: item.answer.slice(0, 1_500) })),
|
|
992
|
+
})),
|
|
993
|
+
spent,
|
|
994
|
+
budget: { ...settings, ...budgetCheck({ estimateUsd: null, spent, ...settings }) },
|
|
995
|
+
biases: routeQualityBiases(saved.runs),
|
|
996
|
+
onboarding: saved.onboarding,
|
|
997
|
+
settings: {
|
|
998
|
+
preset: routingPresetOf(config),
|
|
999
|
+
reviewMode: valueOf(config, 'reviewMode', 'off'),
|
|
1000
|
+
allowManualReassign: valueOf(config, 'allowManualReassign', true) !== false,
|
|
1001
|
+
confirmUnsandboxedCli: valueOf(config, 'confirmUnsandboxedCli', true) !== false,
|
|
1002
|
+
},
|
|
1003
|
+
storage: routerStateStore().file,
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Per-route read/write boundaries for the security card. */
|
|
1008
|
+
export async function securityBoundaries(ctx, config = {}) {
|
|
1009
|
+
const routes = configuredRoutesWithProfiles(await discoverConfiguredRoutes(ctx), config)
|
|
1010
|
+
const readiness = await Promise.all(['claude-code', 'codex'].map(id => officialToolReadiness(id).catch(() => ({ id, ready: false }))))
|
|
1011
|
+
const sandboxedToolIds = readiness.filter(item => item.ready && typeof ctx.sandbox?.confine === 'function').map(item => item.id)
|
|
1012
|
+
return { platform: process.platform, sandboxAvailable: typeof ctx.sandbox?.confine === 'function', boundaries: routeBoundaries(routes, { sandboxedToolIds, platform: process.platform }) }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function explicitRoute(args, routes) {
|
|
1016
|
+
const provider = text(args.provider)
|
|
1017
|
+
const model = text(args.model)
|
|
1018
|
+
if (Boolean(provider) !== Boolean(model)) throw new Error('provider 和 model 必须同时提供。')
|
|
1019
|
+
if (!provider) return null
|
|
1020
|
+
const route = routes.find(item => item.provider === provider && item.model === model)
|
|
1021
|
+
if (!route) throw new Error(`路线 ${provider}/${model} 不在 Harness 模型目录中;可先调用 model_router_routes 查看可用路线。`)
|
|
1022
|
+
return route
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function currentAgentRoute(agent) {
|
|
1026
|
+
try {
|
|
1027
|
+
const config = agent?.session?.requestHeader?.()?.config
|
|
1028
|
+
if (text(config?.provider) && text(config?.model)) return { provider: config.provider, model: config.model }
|
|
1029
|
+
} catch { /* background tools need not have a session-backed agent */ }
|
|
1030
|
+
return null
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function chooseConsultRoute(plan, routes, agent) {
|
|
1034
|
+
const current = currentAgentRoute(agent)
|
|
1035
|
+
const differs = route => current === null || routeKey(route) !== routeKey(current)
|
|
1036
|
+
const preferred = routes.find(route => route.provider === plan.selected?.provider && route.model === plan.selected?.model)
|
|
1037
|
+
if (preferred && differs(preferred)) return preferred
|
|
1038
|
+
return routes.find(differs) ?? preferred ?? routes[0]
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Manual package > manual tool > saved route mapping > CLI default. */
|
|
1042
|
+
export function resolveTeamCliModelBindings(plan, routes, requested = {}) {
|
|
1043
|
+
if (!requested || typeof requested !== 'object' || Array.isArray(requested)) {
|
|
1044
|
+
throw new Error('cliModelsJson 必须是以工作包或官方工具 ID 为键的 JSON 对象')
|
|
1045
|
+
}
|
|
1046
|
+
const byRoute = new Map(routes.map(route => [routeKey(route), route]))
|
|
1047
|
+
const bindings = Object.create(null)
|
|
1048
|
+
for (const item of plan.team.workPackages) {
|
|
1049
|
+
const toolId = toolForProvider(item.recommendedProvider)?.id
|
|
1050
|
+
const profile = byRoute.get(`${item.recommendedProvider}\u0000${item.recommendedModel}`)
|
|
1051
|
+
if (profile?.cliModel && toolId !== 'zcode') bindings[item.id] = profile.cliModel
|
|
1052
|
+
if (Object.hasOwn(requested, toolId)) bindings[item.id] = requested[toolId]
|
|
1053
|
+
if (Object.hasOwn(requested, item.id)) bindings[item.id] = requested[item.id]
|
|
1054
|
+
}
|
|
1055
|
+
// Keep the supplied keys so the runtime can reject unknown tool/package IDs.
|
|
1056
|
+
return { ...bindings, ...Object.fromEntries(Object.entries(requested)
|
|
1057
|
+
.filter(([key]) => !Object.hasOwn(bindings, key))) }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** What was assigned and why, per package: route, difficulty, estimated cost, channel. */
|
|
1061
|
+
export function decisionSummary(plan) {
|
|
1062
|
+
const packages = plan?.routingBypassed
|
|
1063
|
+
? [{ id: 'direct', name: '指定模型', recommendedProvider: plan.selected?.provider, recommendedModel: plan.selected?.model,
|
|
1064
|
+
difficulty: plan.complexity?.band, estimatedCost: plan.estimatedCost, executionChannel: plan.executionChannel, channelDetail: plan.channelDetail }]
|
|
1065
|
+
: plan?.team?.workPackages ?? []
|
|
1066
|
+
return {
|
|
1067
|
+
preset: plan?.preset ?? DEFAULT_ROUTING_PRESET,
|
|
1068
|
+
complexity: plan?.complexity ?? null,
|
|
1069
|
+
reason: plan?.reason ?? '',
|
|
1070
|
+
estimatedCost: plan?.estimatedCost ?? null,
|
|
1071
|
+
packages: packages.map(item => ({
|
|
1072
|
+
id: item.id, name: item.name, route: `${item.recommendedProvider}/${item.recommendedModel}`,
|
|
1073
|
+
difficulty: item.difficulty ?? null, estimatedCost: item.estimatedCost ?? null,
|
|
1074
|
+
channel: item.executionChannel ?? null, channelDetail: item.channelDetail ?? '',
|
|
1075
|
+
dependsOn: item.dependsOn ?? [],
|
|
1076
|
+
})),
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function commandText(plan) {
|
|
1081
|
+
const selected = plan.selected ? `${plan.selected.provider}/${plan.selected.model}` : '没有可用路线'
|
|
1082
|
+
const channel = plan.executionChannel === 'official-cli'
|
|
1083
|
+
? `官方 CLI(${plan.channelLabel ?? plan.channelTool})`
|
|
1084
|
+
: '官方模型目录 API'
|
|
1085
|
+
return [
|
|
1086
|
+
`推荐路线:${selected}`,
|
|
1087
|
+
`方案:${routingPreset(plan.preset).label};复杂度:${plan.complexity.band}(难度分 ${plan.complexity.value});任务类型:${plan.taskType}`,
|
|
1088
|
+
`执行渠道:${channel};估算成本:${plan.estimatedCost === null ? '价格资料不足' : `$${plan.estimatedCost.toFixed(6)}(仅估算)`}`,
|
|
1089
|
+
`工作包:${plan.subtasks.map(item => `${item.name} → ${item.recommendedProvider}/${item.recommended}`).join(';')}`,
|
|
1090
|
+
plan.team.handoff,
|
|
1091
|
+
plan.budgetStatus?.exceeded ? `预算:${plan.budgetStatus.message}` : '',
|
|
1092
|
+
].filter(Boolean).join('\n')
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const HEADLESS_CLI_IDS = new Set(['claude-code', 'codex', 'gemini'])
|
|
1096
|
+
|
|
1097
|
+
/** Whether a read-only execution could start a headless CLI without the Harness sandbox. */
|
|
1098
|
+
function mayLaunchDirectCli(args = {}) {
|
|
1099
|
+
const provider = text(args?.provider)
|
|
1100
|
+
if (provider) {
|
|
1101
|
+
const tool = toolForProvider(provider)
|
|
1102
|
+
if (!tool || !HEADLESS_CLI_IDS.has(tool.id)) return false
|
|
1103
|
+
return healthCache.loginState(tool.id)?.state !== 'logged-out'
|
|
1104
|
+
}
|
|
1105
|
+
const report = healthCache.report()
|
|
1106
|
+
if (!report) return true
|
|
1107
|
+
return report.tools.some(item => HEADLESS_CLI_IDS.has(item.id) && item.installed && healthCache.loginState(item.id)?.state !== 'logged-out')
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const APPROVAL_TEXT = Object.freeze({
|
|
1111
|
+
'workspace-write': sandbox => ({
|
|
1112
|
+
reason: 'Official CLI models will use their normal tools in an isolated Git worktree and integrate their patch into the current workspace',
|
|
1113
|
+
en: `Edit files: the official CLI model may use shell, skills, configured MCP and other normal tools in an isolated Git worktree, then its patch is applied to this workspace. ${sandbox ? 'Launches are wrapped by the Harness process sandbox.' : 'The Harness process sandbox is unavailable, so the launch will be refused.'}`,
|
|
1114
|
+
zh: `修改文件:官方 CLI 模型会在独立 Git 工作树中使用终端、技能、已配置 MCP 等工具,并把改动补丁应用回当前工作区;可写范围为独立工作树及补丁涉及的源文件。${sandbox ? '启动由 Harness 进程沙箱包装(Windows ACL 后端为部分强制)。' : '当前没有 Harness 进程沙箱,启动会被拒绝。'}`,
|
|
1115
|
+
}),
|
|
1116
|
+
'rerun-write': () => ({
|
|
1117
|
+
reason: 'Re-run an editable step on a fresh isolated Git worktree seeded with the earlier changes, then integrate the patch',
|
|
1118
|
+
en: 'Edit files: the step is re-run in a fresh isolated Git worktree that first receives the earlier run\'s changes; on success the combined patch is applied to this workspace (it must be a clean Git repository at the same commit).',
|
|
1119
|
+
zh: '修改文件:在新的独立 Git 工作树中先套用原运行的改动,再重跑该步骤及其下游;成功后把合并补丁应用回当前工作区(要求工作区干净且仍在原基线提交)。',
|
|
1120
|
+
}),
|
|
1121
|
+
'subscription-api': () => ({
|
|
1122
|
+
reason: 'Retry a paused step on the API key after its subscription attempt failed (not a quota limit)',
|
|
1123
|
+
en: 'Use the API key: the subscription attempt for this step failed for a reason other than quota; the retry is billed to the API account.',
|
|
1124
|
+
zh: '改用 API Key 重试:该步骤的订阅调用失败(不是额度用尽或限流),重试会按 API 计费并计入预算。',
|
|
1125
|
+
}),
|
|
1126
|
+
'over-budget': () => ({
|
|
1127
|
+
reason: 'Run although the configured daily or monthly model budget would be exceeded',
|
|
1128
|
+
en: 'Over budget: this run exceeds the configured daily or monthly budget (estimated from configured prices).',
|
|
1129
|
+
zh: '超出预算:本次执行会超出设置的每日或每月预算(按已配置单价估算)。',
|
|
1130
|
+
}),
|
|
1131
|
+
unsandboxed: () => ({
|
|
1132
|
+
reason: 'An official CLI may be started directly, outside the Harness process sandbox, in read-only headless mode',
|
|
1133
|
+
en: 'Unsandboxed CLI: the routed model may run its official CLI directly (claude -p / codex exec / gemini -p) without the Harness process sandbox; read-only is enforced only by the CLI flags.',
|
|
1134
|
+
zh: '不经沙箱启动 CLI:分配的模型可能直接启动其官方 CLI(claude -p / codex exec / gemini -p),不经过 Harness 进程沙箱;只读仅由 CLI 自身参数保证(Codex 可读取当前用户可读的文件)。可在设置中关闭此确认。',
|
|
1135
|
+
}),
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Every reason one model-facing run needs the user's approval, in a fixed
|
|
1140
|
+
* order: file edits, API-key billing, budget, unsandboxed CLI. Returned as
|
|
1141
|
+
* codes so the workbench confirm panel and the approval prompt agree.
|
|
1142
|
+
*/
|
|
1143
|
+
export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = null } = {}) {
|
|
1144
|
+
const codes = []
|
|
1145
|
+
const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run']
|
|
1146
|
+
if (!runKinds.includes(name)) return codes
|
|
1147
|
+
if ((name === 'model_router_tool_run' || name === 'model_router_team_execute') && args.mode === 'workspace-write') codes.push('workspace-write')
|
|
1148
|
+
if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
|
|
1149
|
+
if (name === 'model_router_rerun_step' && args.subscriptionChoice === 'api') codes.push('subscription-api')
|
|
1150
|
+
if (args.confirmOverBudget === true) codes.push('over-budget')
|
|
1151
|
+
const directRun = name === 'model_router_execute' || (name === 'model_router_rerun_step' && (!rerunRun || rerunSupport(rerunRun).kind === 'assign'))
|
|
1152
|
+
if (directRun && valueOf(config, 'confirmUnsandboxedCli', true) !== false && mayLaunchDirectCli(args)) codes.push('unsandboxed')
|
|
1153
|
+
return codes
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function approvalReasons(name, args, config, { sandboxAvailable = false, rerunRun = null } = {}) {
|
|
1157
|
+
return approvalReasonCodes(name, args, config, { rerunRun }).map(code => ({ code, ...APPROVAL_TEXT[code](sandboxAvailable) }))
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** One approval prompt listing every reason, so the user is asked once. */
|
|
1161
|
+
export function combinedAsk(reasons) {
|
|
1162
|
+
if (reasons.length === 1) {
|
|
1163
|
+
const [only] = reasons
|
|
1164
|
+
return { kind: 'ask', reason: only.reason, displayReason: { en: `${only.en} Allow?`, zh: `${only.zh} 允许执行吗?` }, reasons: reasons.map(item => item.code) }
|
|
1165
|
+
}
|
|
1166
|
+
return {
|
|
1167
|
+
kind: 'ask',
|
|
1168
|
+
reason: reasons.map(item => item.reason).join('; '),
|
|
1169
|
+
displayReason: {
|
|
1170
|
+
en: `This run needs your approval for ${reasons.length} reasons:\n${reasons.map((item, index) => `${index + 1}. ${item.en}`).join('\n')}\nAllow?`,
|
|
1171
|
+
zh: `本次执行需要确认以下 ${reasons.length} 项:\n${reasons.map((item, index) => `${index + 1}. ${item.zh}`).join('\n')}\n全部允许并继续吗?`,
|
|
1172
|
+
},
|
|
1173
|
+
reasons: reasons.map(item => item.code),
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Reasons in the workbench's shape: code plus the Chinese and English text. */
|
|
1178
|
+
export function confirmationReasons(codes, { sandboxAvailable = false } = {}) {
|
|
1179
|
+
return codes.map(code => ({ code, zh: APPROVAL_TEXT[code](sandboxAvailable).zh, en: APPROVAL_TEXT[code](sandboxAvailable).en }))
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* The workspace for a run started from the workbench: the user-typed absolute
|
|
1184
|
+
* directory, else the workspace of the latest recorded run. The run is
|
|
1185
|
+
* read-only; the directory only scopes headless CLIs.
|
|
1186
|
+
*/
|
|
1187
|
+
export async function workbenchWorkspace(requested, runs = []) {
|
|
1188
|
+
const chosen = text(requested) || text([...runs].reverse().find(run => text(run?.workspace))?.workspace)
|
|
1189
|
+
if (!chosen) throw new Error('请填写工作区的绝对路径(例如项目根目录);还没有可沿用的历史运行工作区。')
|
|
1190
|
+
if (!isAbsolute(chosen)) throw new Error(`工作区必须是绝对路径:${chosen}`)
|
|
1191
|
+
let info
|
|
1192
|
+
try { info = await stat(chosen) } catch { throw new Error(`工作区不存在:${chosen}。请检查路径后重试。`) }
|
|
1193
|
+
if (!info.isDirectory()) throw new Error(`工作区不是目录:${chosen}`)
|
|
1194
|
+
return chosen
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Plan preview for the workbench: decision, estimate, budget and what needs confirming. */
|
|
1198
|
+
export async function previewWorkbenchRun(ctx, config, request = {}, options = {}) {
|
|
1199
|
+
const saved = await savedState()
|
|
1200
|
+
const workspace = await workbenchWorkspace(request.workspace, saved.runs)
|
|
1201
|
+
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1202
|
+
const planned = await planAssignment(ctx, request.task, config, {
|
|
1203
|
+
provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
|
|
1204
|
+
budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)), workspace, signal: options.signal, ...options.planOptions,
|
|
1205
|
+
})
|
|
1206
|
+
await hydrateHealth()
|
|
1207
|
+
const codes = approvalReasonCodes('model_router_execute', { provider: request.provider, confirmOverBudget: Boolean(planned.budget.exceeded) }, config)
|
|
1208
|
+
return {
|
|
1209
|
+
workspace,
|
|
1210
|
+
decision: decisionSummary(planned.plan),
|
|
1211
|
+
selected: planned.plan.selected ?? null,
|
|
1212
|
+
routingBypassed: planned.plan.routingBypassed === true,
|
|
1213
|
+
estimatedCost: planned.plan.estimatedCost ?? null,
|
|
1214
|
+
budget: planned.budget,
|
|
1215
|
+
reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function' }),
|
|
1216
|
+
readOnly: true,
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Execute from the workbench; refuses until every current reason is in confirmedReasons. */
|
|
1221
|
+
export async function startWorkbenchRun(ctx, config, request = {}, options = {}) {
|
|
1222
|
+
const preview = await previewWorkbenchRun(ctx, config, request, options)
|
|
1223
|
+
const confirmed = new Set(Array.isArray(request.confirmedReasons) ? request.confirmedReasons : [])
|
|
1224
|
+
const missing = preview.reasons.filter(item => !confirmed.has(item.code))
|
|
1225
|
+
if (missing.length) return { status: 'needs-confirmation', ...preview, reasons: preview.reasons, missing: missing.map(item => item.code) }
|
|
1226
|
+
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1227
|
+
const result = await (options.execute ?? executeConfiguredAssignment)(ctx, request.task, config, {
|
|
1228
|
+
provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
|
|
1229
|
+
budgetUsd: finiteNumber(request.budgetUsd, finiteNumber(configuredBudget, 0)),
|
|
1230
|
+
confirmOverBudget: confirmed.has('over-budget'), workspace: preview.workspace, signal: options.signal, ...options.planOptions,
|
|
1231
|
+
})
|
|
1232
|
+
const awaiting = result.awaitingConfirmation ?? []
|
|
1233
|
+
return {
|
|
1234
|
+
status: result.paused ? 'paused-budget' : awaiting.length ? 'paused-subscription-failure' : result.execution?.status ?? 'unknown',
|
|
1235
|
+
runId: result.runId ?? null, workspace: preview.workspace, budget: result.budget, decision: decisionSummary(result.plan),
|
|
1236
|
+
...(awaiting.length ? { awaitingConfirmation: awaiting } : {}),
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Host operations behind the workbench RPC; the client never supplies commands or paths. */
|
|
1241
|
+
export function routerRemoteServices(ctx, config) {
|
|
1242
|
+
return {
|
|
1243
|
+
health: async fresh => {
|
|
1244
|
+
const report = await toolHealthReport({ fresh: fresh === true })
|
|
1245
|
+
const saved = await savedState()
|
|
1246
|
+
return { ...report, onboarding: saved.onboarding, notices: recentNotices(saved), billing: await billingHealth(ctx, config) }
|
|
1247
|
+
},
|
|
1248
|
+
completeOnboarding: async () => routerStateStore().completeOnboarding(Date.now()),
|
|
1249
|
+
ledger: () => ledgerSummary(config),
|
|
1250
|
+
rate: request => rateRecordedResult(request),
|
|
1251
|
+
rerun: request => rerunRecordedStep(ctx, config, {
|
|
1252
|
+
runId: request?.runId, packageId: request?.packageId, provider: request?.provider, model: request?.model,
|
|
1253
|
+
confirmOverBudget: request?.confirmOverBudget === true, confirmWrite: request?.confirmWrite === true,
|
|
1254
|
+
...(request?.subscriptionChoice ? { subscriptionChoice: request.subscriptionChoice } : {}),
|
|
1255
|
+
}),
|
|
1256
|
+
boundaries: () => securityBoundaries(ctx, config),
|
|
1257
|
+
previewRun: request => previewWorkbenchRun(ctx, config, request),
|
|
1258
|
+
startRun: request => startWorkbenchRun(ctx, config, request),
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Register model-facing tools and the human /router command. */
|
|
1263
|
+
export function apply(ctx, config = {}) {
|
|
1264
|
+
// The official Host injects typert; direct lightweight uses of apply may
|
|
1265
|
+
// supply only the model/command services and do not expose the Desktop RPC.
|
|
1266
|
+
if (ctx.typert) registerOfficialToolsRemote(ctx, routerRemoteServices(ctx, config))
|
|
1267
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
1268
|
+
const decision = await next()
|
|
1269
|
+
if (decision.kind !== 'allow') return decision
|
|
1270
|
+
if (exec.name === 'model_router_tool_install') {
|
|
1271
|
+
const requested = getOfficialTool(text(exec.arguments?.tool))
|
|
1272
|
+
const label = requested?.label ?? '官方 CLI'
|
|
1273
|
+
const desktopInstaller = requested?.manager === 'signed-windows-installer'
|
|
1274
|
+
return {
|
|
1275
|
+
kind: 'ask',
|
|
1276
|
+
reason: desktopInstaller
|
|
1277
|
+
? `Download and open the verified official ${label} desktop installer`
|
|
1278
|
+
: `Install ${label} globally with the plugin's fixed official command`,
|
|
1279
|
+
displayReason: {
|
|
1280
|
+
en: desktopInstaller
|
|
1281
|
+
? `Download and open the verified ${label} installer? You can select the installation directory in its window.`
|
|
1282
|
+
: `Install ${label} globally using the fixed official package?`,
|
|
1283
|
+
zh: desktopInstaller
|
|
1284
|
+
? `下载并打开已验签的 ${label} 安装器?安装窗口中可选择非 C 盘目录。`
|
|
1285
|
+
: `使用插件固定的官方软件包,在本机全局安装 ${label}?`,
|
|
1286
|
+
},
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
let rerunRun = null
|
|
1290
|
+
if (exec.name === 'model_router_rerun_step') {
|
|
1291
|
+
try { rerunRun = (await savedState()).runs.find(item => item.id === text(exec.arguments?.runId)) ?? null } catch { rerunRun = null }
|
|
1292
|
+
}
|
|
1293
|
+
const reasons = approvalReasons(exec.name, exec.arguments ?? {}, config, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function', rerunRun })
|
|
1294
|
+
if (reasons.length) return combinedAsk(reasons)
|
|
1295
|
+
return decision
|
|
1296
|
+
})
|
|
1297
|
+
registerOfficialToolModels(ctx, config)
|
|
1298
|
+
ctx.tools.register(defineTool({
|
|
1299
|
+
name: 'model_router_routes',
|
|
1300
|
+
description: 'List provider/model routes registered in the official DeepSeek Harness model directory. Credential and network availability are not verified. No API keys or endpoints are returned.',
|
|
1301
|
+
parameters: {},
|
|
1302
|
+
output: JSON_OUTPUT,
|
|
1303
|
+
async execute(_args, exec) {
|
|
1304
|
+
const routes = await discoverConfiguredRoutes(ctx, exec.signal)
|
|
1305
|
+
return jsonValue({ routes, count: routes.length, availabilityNotice: '目录记录不证明账号凭据和网络当前可用。' })
|
|
1306
|
+
},
|
|
1307
|
+
}))
|
|
1308
|
+
ctx.tools.register(defineTool({
|
|
1309
|
+
name: 'model_router_plan',
|
|
1310
|
+
description: 'Analyze task difficulty, recommend only configured Harness model routes, and optionally split compound work into dependent packages. Cost estimates require user-supplied USD prices for the exact routes.',
|
|
1311
|
+
parameters: {
|
|
1312
|
+
task: { type: 'string', required: true, description: 'Task to analyze.' },
|
|
1313
|
+
mode: { type: 'string', enum: ['single', 'team'], description: 'Use team to produce Agent Teams work packages.' },
|
|
1314
|
+
budgetUsd: { type: 'number', description: 'Optional local estimated cost ceiling in USD.' },
|
|
1315
|
+
},
|
|
1316
|
+
output: JSON_OUTPUT,
|
|
1317
|
+
async execute(args, exec) {
|
|
1318
|
+
return jsonValue(await createRoutePlan(ctx, args.task, config, { mode: args.mode, budgetUsd: args.budgetUsd, signal: exec.signal }))
|
|
1319
|
+
},
|
|
1320
|
+
}))
|
|
1321
|
+
ctx.tools.register(defineTool({
|
|
1322
|
+
name: 'model_router_consult',
|
|
1323
|
+
description: 'Ask one already configured Harness model for an independent opinion. Supply both provider and model for an explicit route, or omit both for a recommended route different from the current model when available.',
|
|
1324
|
+
parameters: {
|
|
1325
|
+
task: { type: 'string', required: true, description: 'Task or question for the consulted model.' },
|
|
1326
|
+
provider: { type: 'string', description: 'Configured provider id; pair with model.' },
|
|
1327
|
+
model: { type: 'string', description: 'Configured model id; pair with provider.' },
|
|
1328
|
+
outputLimit: { type: 'number', description: 'Maximum returned characters, from 500 to 50000.' },
|
|
1329
|
+
},
|
|
1330
|
+
output: JSON_OUTPUT,
|
|
1331
|
+
async execute(args, exec) {
|
|
1332
|
+
const routes = await discoverConfiguredRoutes(ctx, exec.signal)
|
|
1333
|
+
if (routes.length === 0) throw new Error('Harness 模型目录中没有可用路线;请先在官方“模型”页添加模型。')
|
|
1334
|
+
const requested = explicitRoute(args, routes)
|
|
1335
|
+
const plan = requested ? null : await createRoutePlan(ctx, args.task, config, { signal: exec.signal })
|
|
1336
|
+
const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
|
|
1337
|
+
const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
|
|
1338
|
+
const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
|
|
1339
|
+
return jsonValue(await consultConfiguredModel(ctx, route, args.task, limit, exec.signal))
|
|
1340
|
+
},
|
|
1341
|
+
}))
|
|
1342
|
+
ctx.commands.register({
|
|
1343
|
+
name: 'router',
|
|
1344
|
+
description: 'Show an official-model route recommendation for a task.',
|
|
1345
|
+
input: { hint: 'Describe the task to plan' },
|
|
1346
|
+
async handler({ rawInput, signal }) {
|
|
1347
|
+
if (!text(rawInput)) return { kind: 'error', text: '用法:/router <需要规划的任务>' }
|
|
1348
|
+
try {
|
|
1349
|
+
const plan = await createRoutePlan(ctx, rawInput, config, { signal })
|
|
1350
|
+
return { kind: 'success', text: commandText(plan) }
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
return { kind: 'error', text: `无法生成路由计划:${errorText(error)}` }
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
})
|
|
1356
|
+
ctx.commands.register({
|
|
1357
|
+
name: 'tools',
|
|
1358
|
+
description: 'Show official model CLI install status, or install one registry tool.',
|
|
1359
|
+
input: { hint: '留空查看状态;或输入 install/cancel <工具id>' },
|
|
1360
|
+
async handler({ rawInput, signal }) {
|
|
1361
|
+
const input = text(rawInput)
|
|
1362
|
+
const installMatch = input.match(/^install\s+([A-Za-z0-9_-]+)$/i)
|
|
1363
|
+
const cancelMatch = input.match(/^cancel\s+([A-Za-z0-9_-]+)$/i)
|
|
1364
|
+
if (cancelMatch) {
|
|
1365
|
+
try { return { kind: 'success', text: `已请求取消 ${cancelInstall(cancelMatch[1]).tool} 的安装;请使用 /tools 查看最新状态。` } }
|
|
1366
|
+
catch (error) { return { kind: 'error', text: `无法取消安装:${errorText(error)}` } }
|
|
1367
|
+
}
|
|
1368
|
+
if (!installMatch) {
|
|
1369
|
+
if (input) return { kind: 'error', text: '用法:/tools 查看状态,或 /tools install/cancel <工具id>' }
|
|
1370
|
+
try {
|
|
1371
|
+
const probes = await probeAllTools({ fresh: true })
|
|
1372
|
+
const lines = probes.map(probe => {
|
|
1373
|
+
const tool = getOfficialTool(probe.id)
|
|
1374
|
+
const command = installCommandLine(tool)
|
|
1375
|
+
const status = probe.status === 'installed'
|
|
1376
|
+
? `已安装 ${probe.version ?? ''}`
|
|
1377
|
+
: probe.status === 'unsupported'
|
|
1378
|
+
? '不支持一键安装'
|
|
1379
|
+
: '未安装'
|
|
1380
|
+
return `• ${tool.label}(${probe.id}):${status}${command && probe.status === 'not-installed' ? `\n 安装:/tools install ${probe.id}(即 ${command})` : ''}`
|
|
1381
|
+
})
|
|
1382
|
+
return { kind: 'success', text: `官方工具状态:\n${lines.join('\n')}` }
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
return { kind: 'error', text: `探测失败:${errorText(error)}` }
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
const job = startInstall(installMatch[1])
|
|
1389
|
+
const tool = getOfficialTool(installMatch[1])
|
|
1390
|
+
const settled = await waitForInstall(job.tool, signal)
|
|
1391
|
+
if (settled.status === 'succeeded') {
|
|
1392
|
+
const probe = await probeToolWith(tool, defaultRunner)
|
|
1393
|
+
return { kind: 'success', text: `${tool.label} 安装完成${probe.version ? `,探测版本 ${probe.version}` : ''}。` }
|
|
1394
|
+
}
|
|
1395
|
+
if (settled.status === 'installer-opened') {
|
|
1396
|
+
return { kind: 'success', text: `${tool.label} 官方安装器已验证并打开。请在安装窗口选择非 C 盘目录并完成安装,然后运行 /tools 重新检测;当前尚未确认安装完成。` }
|
|
1397
|
+
}
|
|
1398
|
+
return { kind: 'error', text: `${tool.label} 安装失败:${settled.error ?? '未知原因'}\n${settled.outputTail.slice(-6).join('\n')}` }
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
return { kind: 'error', text: `无法开始安装:${errorText(error)}` }
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
})
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Poll an install job until it settles or the signal aborts. */
|
|
1407
|
+
async function waitForInstall(toolId, signal) {
|
|
1408
|
+
for (;;) {
|
|
1409
|
+
if (signal?.aborted) {
|
|
1410
|
+
try { cancelInstall(toolId) } catch { /* install may already have finished */ }
|
|
1411
|
+
throw new Error('已请求取消安装;请重新检测实际安装状态。')
|
|
1412
|
+
}
|
|
1413
|
+
const job = installStatus(toolId)
|
|
1414
|
+
if (!job) throw new Error('安装任务丢失。')
|
|
1415
|
+
if (job.status !== 'running') return job
|
|
1416
|
+
await new Promise(resolve => setTimeout(resolve, 1_500))
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/** Register the model-facing official-tool probes and installer. */
|
|
1421
|
+
function registerOfficialToolModels(ctx, config) {
|
|
1422
|
+
ctx.tools.register(defineTool({
|
|
1423
|
+
name: 'model_router_tools',
|
|
1424
|
+
description: 'Probe the fixed registry of official model tools (Kimi Code, Claude Code, Codex, MiniMax Code, MiMo Code, Grok Build, ZCode, Gemini CLI) and report which are installed with their versions. Never reads credentials.',
|
|
1425
|
+
parameters: {},
|
|
1426
|
+
output: JSON_OUTPUT,
|
|
1427
|
+
async execute(_args, exec) {
|
|
1428
|
+
throwIfAborted(exec.signal)
|
|
1429
|
+
const probes = await probeAllTools()
|
|
1430
|
+
return jsonValue({
|
|
1431
|
+
tools: probes,
|
|
1432
|
+
executionCapabilities: officialToolExecutionCapabilities(),
|
|
1433
|
+
executionReadiness: await Promise.all(probes.map(probe => probe.installed
|
|
1434
|
+
? officialToolReadiness(probe.id)
|
|
1435
|
+
: Promise.resolve({ id: probe.id, ready: false, reason: 'CLI 尚未安装或版本检测失败。' }))),
|
|
1436
|
+
installHint: '未安装的工具可由 model_router_tool_install 按注册表固定命令安装;版本与包名不接受自定义。',
|
|
1437
|
+
})
|
|
1438
|
+
},
|
|
1439
|
+
}))
|
|
1440
|
+
ctx.tools.register(defineTool({
|
|
1441
|
+
name: 'model_router_tool_install',
|
|
1442
|
+
description: 'Install one official model tool by registry id using its pinned official source. ZCode opens a verified interactive desktop installer with a directory picker. Only registry ids are accepted; arbitrary packages or executables are refused.',
|
|
1443
|
+
parameters: {
|
|
1444
|
+
tool: { type: 'string', required: true, description: 'Registry tool id, e.g. kimi-code.' },
|
|
1445
|
+
},
|
|
1446
|
+
output: JSON_OUTPUT,
|
|
1447
|
+
async execute(args, exec) {
|
|
1448
|
+
const job = startInstall(args.tool)
|
|
1449
|
+
const settled = await waitForInstall(job.tool, exec.signal)
|
|
1450
|
+
const tool = getOfficialTool(args.tool)
|
|
1451
|
+
const probe = settled.status === 'succeeded'
|
|
1452
|
+
? await probeToolWith(tool, defaultRunner)
|
|
1453
|
+
: null
|
|
1454
|
+
return jsonValue({
|
|
1455
|
+
...settled,
|
|
1456
|
+
postInstallProbe: probe,
|
|
1457
|
+
notice: settled.status === 'installer-opened'
|
|
1458
|
+
? '已打开 ZCode 官方安装窗口,请选择安装目录并完成安装,之后重新检测;此状态不代表安装完成。'
|
|
1459
|
+
: '安装命令完全来自服务端注册表;实际版本以探测横幅为准。',
|
|
1460
|
+
})
|
|
1461
|
+
},
|
|
1462
|
+
}))
|
|
1463
|
+
ctx.tools.register(defineTool({
|
|
1464
|
+
name: 'model_router_tool_run',
|
|
1465
|
+
description: 'Run one supported official model CLI in the current Harness session workspace. Claude, Codex, MiMo and Grok support read-only; Kimi, MiniMax and ZCode require workspace-write. Write mode needs approval and a clean Git repository. Optional provider/model must match a configured Harness route and selected vendor; cliModel can specify that vendor CLI’s own configured model name. Without cliModel, Kimi, MiniMax, MiMo, Grok and ZCode use the CLI default.',
|
|
1466
|
+
parameters: {
|
|
1467
|
+
tool: { type: 'string', required: true, description: 'Fixed registry tool id, e.g. claude-code or codex.' },
|
|
1468
|
+
task: { type: 'string', required: true, description: 'Concrete task for the official CLI model.' },
|
|
1469
|
+
provider: { type: 'string', description: 'Optional configured provider, paired with model.' },
|
|
1470
|
+
model: { type: 'string', description: 'Optional model ID from the Harness directory, paired with provider. This ID is advisory for CLIs except Claude/Codex.' },
|
|
1471
|
+
cliModel: { type: 'string', description: 'Optional model name already configured in this vendor CLI; requires provider and model. MiniMax/MiMo require provider/model format. ZCode 3.14.3 cannot switch models per call.' },
|
|
1472
|
+
mode: { type: 'string', enum: ['read-only', 'workspace-write'], description: 'Default is read-only. Kimi, MiniMax and ZCode require workspace-write. Write mode requires official approval and a clean Git repository.' },
|
|
1473
|
+
confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the daily/monthly budget. Triggers an approval prompt.' },
|
|
1474
|
+
},
|
|
1475
|
+
output: JSON_OUTPUT,
|
|
1476
|
+
async execute(args, exec) {
|
|
1477
|
+
const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
|
|
1478
|
+
const planned = await planToolRun(ctx, config, args, { signal: exec.signal })
|
|
1479
|
+
if (planned.mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑 CLI 执行')
|
|
1480
|
+
if (planned.budget.exceeded && args.confirmOverBudget !== true) {
|
|
1481
|
+
return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.estimate,
|
|
1482
|
+
notice: `${planned.budget.message} 已暂停,未启动 ${planned.tool.label}。请向用户说明后,以 confirmOverBudget: true 重新调用。` })
|
|
1483
|
+
}
|
|
1484
|
+
const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal })
|
|
1485
|
+
return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.estimate, budget: planned.budget,
|
|
1486
|
+
...(!planned.modelId && planned.model ? { modelNotice: result.modelNotice
|
|
1487
|
+
?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),
|
|
1488
|
+
...(planned.estimate === null ? { costNotice: '未指定带单价的 provider/model 路线,无法预估费用;仅在预算已用尽时暂停。' } : {}),
|
|
1489
|
+
})
|
|
1490
|
+
},
|
|
1491
|
+
}))
|
|
1492
|
+
ctx.tools.register(defineTool({
|
|
1493
|
+
name: 'model_router_execute',
|
|
1494
|
+
description: 'Run a task on configured models and aggregate the results. Omit provider and model to route first. Supply both to use that one model and skip routing. auto/official profiles try the vendor headless CLI (claude -p, codex exec, gemini -p) and fall back to the Harness model API when the CLI is missing or fails. api profiles always use the model API. This call is read-only and does not apply file edits.',
|
|
1495
|
+
parameters: {
|
|
1496
|
+
task: { type: 'string', required: true, description: 'Task to execute.' },
|
|
1497
|
+
provider: { type: 'string', description: 'Configured provider. Pair with model to bypass routing.' },
|
|
1498
|
+
model: { type: 'string', description: 'Configured model. Pair with provider to bypass routing.' },
|
|
1499
|
+
planMode: { type: 'string', enum: ['single', 'team'], description: 'Used only when provider and model are omitted.' },
|
|
1500
|
+
budgetUsd: { type: 'number', description: 'Optional local estimate ceiling when routing. Not a vendor billing cap.' },
|
|
1501
|
+
preset: { type: 'string', enum: ['economy', 'balanced', 'quality'], description: 'Optional routing preset for this call; defaults to the saved setting.' },
|
|
1502
|
+
confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the daily/monthly budget. Triggers an approval prompt.' },
|
|
1503
|
+
},
|
|
1504
|
+
output: JSON_OUTPUT,
|
|
1505
|
+
async execute(args, exec) {
|
|
1506
|
+
const { cwd } = await sessionWorkspace(ctx, exec)
|
|
1507
|
+
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1508
|
+
const result = await executeConfiguredAssignment(ctx, args.task, config, {
|
|
1509
|
+
provider: args.provider,
|
|
1510
|
+
model: args.model,
|
|
1511
|
+
planMode: args.planMode,
|
|
1512
|
+
preset: args.preset,
|
|
1513
|
+
budgetUsd: finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)),
|
|
1514
|
+
confirmOverBudget: args.confirmOverBudget === true,
|
|
1515
|
+
workspace: cwd,
|
|
1516
|
+
signal: exec.signal,
|
|
1517
|
+
})
|
|
1518
|
+
return jsonValue({
|
|
1519
|
+
routingBypassed: result.plan.routingBypassed === true,
|
|
1520
|
+
selected: result.plan.selected,
|
|
1521
|
+
decision: decisionSummary(result.plan),
|
|
1522
|
+
budget: result.budget,
|
|
1523
|
+
...(result.paused ? { status: 'paused-budget' } : result.awaitingConfirmation ? { status: 'paused-subscription-failure', awaitingConfirmation: result.awaitingConfirmation } : {}),
|
|
1524
|
+
execution: result.execution,
|
|
1525
|
+
runId: result.runId ?? null,
|
|
1526
|
+
reviews: result.reviews ?? [],
|
|
1527
|
+
notice: result.paused
|
|
1528
|
+
? '已因预算暂停,未启动任何模型。请向用户说明预估费用,用户同意后再以 confirmOverBudget: true 调用。'
|
|
1529
|
+
: result.awaitingConfirmation ? SUBSCRIPTION_PAUSE_NOTICE
|
|
1530
|
+
: '官方 CLI 以只读无界面方式运行。可编辑改动仍使用 model_router_tool_run 或 model_router_team_execute。未安装、未登录、失败或配置为 api 的模型走模型目录 API;回退原因与 CLI 原始错误见 fallback。可用 model_router_rerun_step 重跑失败步骤,model_router_rate 记录评价。',
|
|
1531
|
+
})
|
|
1532
|
+
},
|
|
1533
|
+
}))
|
|
1534
|
+
ctx.tools.register(defineTool({
|
|
1535
|
+
name: 'model_router_health',
|
|
1536
|
+
description: 'Onboarding health check: for each official CLI in the fixed registry, report installed, version vs pinned version, and login state (cheap status commands only; never starts a login), plus a per-provider billing table: billing mode (subscription-first by default), subscription state (logged in / coding-plan key route / exhausted until a time) and whether an API-key route is available for fallback. Logged-out tools are skipped by routing until re-checked.',
|
|
1537
|
+
parameters: {
|
|
1538
|
+
fresh: { type: 'boolean', description: 'Re-probe instead of using the 60s probe cache.' },
|
|
1539
|
+
},
|
|
1540
|
+
output: JSON_OUTPUT,
|
|
1541
|
+
async execute(args, exec) {
|
|
1542
|
+
throwIfAborted(exec.signal)
|
|
1543
|
+
const report = await toolHealthReport({ fresh: args.fresh === true })
|
|
1544
|
+
return jsonValue({ ...report, notices: recentNotices(await savedState()), billing: await billingHealth(ctx, config, { signal: exec.signal }) })
|
|
1545
|
+
},
|
|
1546
|
+
}))
|
|
1547
|
+
ctx.tools.register(defineTool({
|
|
1548
|
+
name: 'model_router_rerun_step',
|
|
1549
|
+
description: 'Re-run one failed work package of a recorded model_router_execute or model_router_team_execute run without restarting finished packages; unfinished downstream packages follow. An editable (workspace-write) team run that stopped at a failed step continues in a fresh isolated Git worktree seeded with the earlier changes (needs approval, a clean repository at the same commit). A failed model_router_tool_run call (packageId "direct") is repeated as a new linked run. Supply provider and model to reassign a routed or team package to another configured route (if the user allows manual reassignment).',
|
|
1550
|
+
parameters: {
|
|
1551
|
+
runId: { type: 'string', required: true, description: 'runId returned by model_router_execute.' },
|
|
1552
|
+
packageId: { type: 'string', required: true, description: 'Work package id to re-run.' },
|
|
1553
|
+
provider: { type: 'string', description: 'Optional configured provider to reassign to; pair with model.' },
|
|
1554
|
+
model: { type: 'string', description: 'Optional configured model to reassign to; pair with provider.' },
|
|
1555
|
+
confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the budget. Triggers an approval prompt.' },
|
|
1556
|
+
subscriptionChoice: { type: 'string', enum: ['api', 'subscription', 'cancel'], description: 'Only for a step paused after a non-quota subscription failure, and only with the user\'s decision: api = retry on the API key (approval prompt), subscription = retry the subscription, cancel = stop the step and its waiting downstream steps.' },
|
|
1557
|
+
},
|
|
1558
|
+
output: JSON_OUTPUT,
|
|
1559
|
+
async execute(args, exec) {
|
|
1560
|
+
const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
|
|
1561
|
+
const stored = (await savedState()).runs.find(item => item.id === text(args.runId))
|
|
1562
|
+
if (stored && rerunSupport(stored).writes && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能重跑可编辑的运行')
|
|
1563
|
+
// The pre-execute hook already asked the user about file edits for editable reruns.
|
|
1564
|
+
const result = await rerunRecordedStep(ctx, config, {
|
|
1565
|
+
runId: args.runId, packageId: args.packageId, provider: args.provider, model: args.model,
|
|
1566
|
+
confirmOverBudget: args.confirmOverBudget === true, confirmWrite: true, workspace: cwd, root, signal: exec.signal,
|
|
1567
|
+
...(text(args.subscriptionChoice) ? { subscriptionChoice: text(args.subscriptionChoice) } : {}),
|
|
1568
|
+
})
|
|
1569
|
+
return jsonValue({
|
|
1570
|
+
...(result.paused ? { status: 'paused-budget' } : result.cancelled ? { status: 'cancelled' } : result.awaitingConfirmation ? { status: 'paused-subscription-failure', awaitingConfirmation: result.awaitingConfirmation, notice: SUBSCRIPTION_PAUSE_NOTICE } : {}),
|
|
1571
|
+
budget: result.budget, execution: result.execution ?? null, runId: result.run?.id ?? args.runId,
|
|
1572
|
+
...(result.rerunOf ? { rerunOf: result.rerunOf, newRunId: result.newRunId } : {}),
|
|
1573
|
+
})
|
|
1574
|
+
},
|
|
1575
|
+
}))
|
|
1576
|
+
ctx.tools.register(defineTool({
|
|
1577
|
+
name: 'model_router_rate',
|
|
1578
|
+
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.',
|
|
1579
|
+
parameters: {
|
|
1580
|
+
runId: { type: 'string', required: true, description: 'runId returned by model_router_execute.' },
|
|
1581
|
+
packageId: { type: 'string', required: true, description: 'Work package id.' },
|
|
1582
|
+
rating: { type: 'string', required: true, enum: ['up', 'down', 'clear'], description: 'The user\'s rating.' },
|
|
1583
|
+
},
|
|
1584
|
+
output: JSON_OUTPUT,
|
|
1585
|
+
async execute(args) {
|
|
1586
|
+
const run = await rateRecordedResult(args)
|
|
1587
|
+
return jsonValue({ ok: true, runId: run.id, packageId: args.packageId, rating: args.rating })
|
|
1588
|
+
},
|
|
1589
|
+
}))
|
|
1590
|
+
ctx.tools.register(defineTool({
|
|
1591
|
+
name: 'model_router_team_execute',
|
|
1592
|
+
description: 'Plan a complex task into dependent work packages, route among configured providers with ready official CLIs, and run each package sequentially. Claude/Codex request the planned model ID; other CLIs use their configured default unless cliModelsJson supplies exact CLI names. Editable runs use one isolated Git worktree and integrate source changes after CLI success. Confirm the actual model from vendor records.',
|
|
1593
|
+
parameters: {
|
|
1594
|
+
task: { type: 'string', required: true, description: 'Full task to plan, distribute and execute.' },
|
|
1595
|
+
mode: { type: 'string', enum: ['read-only', 'workspace-write'], description: 'Default read-only; workspace-write needs a clean Git repository and approval.' },
|
|
1596
|
+
budgetUsd: { type: 'number', description: 'Estimated planning ceiling only, not a vendor billing limit.' },
|
|
1597
|
+
cliModelsJson: { type: 'string', description: 'Optional JSON object mapping official tool IDs or work package IDs to exact model names configured in those CLIs. MiniMax/MiMo require provider/model; ZCode 3.14.3 cannot switch per call.' },
|
|
1598
|
+
confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the daily/monthly budget. Triggers an approval prompt.' },
|
|
1599
|
+
},
|
|
1600
|
+
output: JSON_OUTPUT,
|
|
1601
|
+
async execute(args, exec) {
|
|
1602
|
+
const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
|
|
1603
|
+
const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
1604
|
+
if (mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑团队执行')
|
|
1605
|
+
const [discoveredRoutes, installed] = await Promise.all([discoverConfiguredRoutes(ctx, exec.signal), installedToolIds()])
|
|
1606
|
+
const routes = configuredRoutesWithProfiles(discoveredRoutes, config)
|
|
1607
|
+
const readiness = await Promise.all(installed.map(id => officialToolReadiness(id, cwd)))
|
|
1608
|
+
const supported = new Set(readiness.filter(item => item.ready).map(item => item.id))
|
|
1609
|
+
const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
|
|
1610
|
+
const executableRoutes = routes.filter(route => {
|
|
1611
|
+
const tool = toolForProvider(route.provider)
|
|
1612
|
+
return tool && installed.includes(tool.id) && supported.has(tool.id)
|
|
1613
|
+
&& capabilities.get(tool.id)?.modes?.includes(mode)
|
|
1614
|
+
})
|
|
1615
|
+
if (executableRoutes.length === 0) return jsonValue({ status: 'blocked',
|
|
1616
|
+
reason: `官方模型目录中没有同时满足已配置路线、已安装 CLI、托管执行适配器与 ${mode} 模式的供应商;Kimi、MiniMax、ZCode 仅支持经审批的 workspace-write。`,
|
|
1617
|
+
installed, executionCapabilities: officialToolExecutionCapabilities(), executionReadiness: readiness })
|
|
1618
|
+
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1619
|
+
const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
|
|
1620
|
+
const plan = createPlanFromRoutes(args.task, executableRoutes, {
|
|
1621
|
+
mode: 'team', budgetUsd, installedToolIds: installed,
|
|
1622
|
+
runnableToolIds: installed.filter(id => supported.has(id)),
|
|
1623
|
+
preset: routingPresetOf(config),
|
|
1624
|
+
})
|
|
1625
|
+
const budget = budgetFor(config, (await savedState()).runs, plan.estimatedCost)
|
|
1626
|
+
if (budget.exceeded && args.confirmOverBudget !== true) {
|
|
1627
|
+
return jsonValue({ status: 'paused-budget', budget, plan,
|
|
1628
|
+
notice: `${budget.message} 已暂停团队执行。请向用户确认后以 confirmOverBudget: true 重新调用。` })
|
|
1629
|
+
}
|
|
1630
|
+
const bindingsText = text(args.cliModelsJson)
|
|
1631
|
+
if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
|
|
1632
|
+
let cliModels = null
|
|
1633
|
+
if (bindingsText) {
|
|
1634
|
+
try { cliModels = JSON.parse(bindingsText) }
|
|
1635
|
+
catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
|
|
1636
|
+
}
|
|
1637
|
+
cliModels = resolveTeamCliModelBindings(plan, executableRoutes, cliModels ?? {})
|
|
1638
|
+
const startedAt = Date.now()
|
|
1639
|
+
const execution = await runOfficialTeam({ plan, task: args.task, workspace: cwd,
|
|
1640
|
+
allowedRoot: root, mode, installedIds: installed, cliModels, signal: exec.signal, sandbox: ctx.sandbox })
|
|
1641
|
+
const recorded = await recordTeamRun({ task: args.task, plan, execution, mode, workspace: cwd,
|
|
1642
|
+
routes: executableRoutes, cliModels, budget, startedAt, config })
|
|
1643
|
+
return jsonValue({ plan, execution, runId: recorded?.id ?? null,
|
|
1644
|
+
rerunNotice: mode === 'read-only'
|
|
1645
|
+
? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
|
|
1646
|
+
: '若在某个步骤失败而停止,可用 model_router_rerun_step 在新的独立工作树中套用之前的改动后续跑(需审批);已整合或待人工整合的运行不能单步重跑。',
|
|
1647
|
+
modelNotice: '已配置 cliModel 的路线按工作包传给官方 CLI;Claude/Codex 在未配置映射时请求 Harness 模型 ID。其他厂商无映射时使用 CLI 默认模型。多数 CLI 尚不返回可核验的实际模型 ID,须以厂商运行记录核对。',
|
|
1648
|
+
billingNotice: 'budgetUsd 仅影响估算与路由,无法限制官方 CLI 账号实际费用。' })
|
|
1649
|
+
},
|
|
1650
|
+
}))
|
|
1651
|
+
}
|