@ljwei-stak/dsh-model-router 0.23.1 → 0.25.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 +2534 -4455
- package/.dsh-plugin/index.mjs +387 -79
- package/.dsh-plugin/shared/budget-reservations.mjs +273 -0
- package/.dsh-plugin/shared/execution-accounting.mjs +61 -0
- package/.dsh-plugin/shared/execution-lifecycle.mjs +143 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +42 -13
- package/.dsh-plugin/shared/official-tools-runtime.mjs +3 -2
- package/.dsh-plugin/shared/router-state.mjs +36 -8
- package/.dsh-plugin/shared/run-ledger.mjs +21 -2
- package/.dsh-plugin/shared/subscription-usage.mjs +210 -0
- package/.dsh-plugin/shared/task-executors.mjs +125 -23
- package/.dsh-plugin/shared/zcode-bundle.mjs +3 -3
- package/CHANGELOG.md +23 -0
- package/README.i18n.yaml +2 -2
- package/README.md +35 -22
- package/README.zh.md +32 -20
- package/docs/WORKBENCH_USER_GUIDE.zh.md +8 -6
- package/docs/assets/workbench-budget-reservations.png +0 -0
- package/docs/assets/workbench-subscription-usage.png +0 -0
- package/docs/releases/0.24.0-validation.md +41 -0
- package/docs/releases/0.25.0-validation.md +41 -0
- package/package.json +11 -3
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
} from './shared/subscription-billing.mjs'
|
|
14
14
|
import { createRouterState } from './shared/router-state.mjs'
|
|
15
15
|
import { createRunJobs } from './shared/run-jobs.mjs'
|
|
16
|
+
import { createBudgetReservations, reservationSpending } from './shared/budget-reservations.mjs'
|
|
17
|
+
import { beginExecutionLifecycle } from './shared/execution-lifecycle.mjs'
|
|
18
|
+
import { attachPlanAccounting, accountingEstimates, initialFunding, planningFundingPrices } from './shared/execution-accounting.mjs'
|
|
19
|
+
import { subscriptionUsage } from './shared/subscription-usage.mjs'
|
|
16
20
|
import { applyFeedbackProfile, buildFeedbackProfile } from './shared/adaptive-feedback.mjs'
|
|
17
21
|
import { buildRoutingEvidence, parseRoutingEvidenceJson } from './shared/routing-evidence.mjs'
|
|
18
22
|
import { apiReasoningRequest } from './shared/reasoning-execution.mjs'
|
|
@@ -21,10 +25,10 @@ import { applyPricingSnapshot, createDynamicDataRefresher, dynamicDataSummary, r
|
|
|
21
25
|
import { projectDynamicData } from './shared/dynamic-data-view.mjs'
|
|
22
26
|
import {
|
|
23
27
|
budgetCheck, buildRunRecord, formatUsd, buildTeamRunRecord, buildToolRunRecord, billingOf, mergeRerun,
|
|
24
|
-
spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults,
|
|
28
|
+
spending, storedResults, actualCost, hasStartedQualityAttempts, teamExecutionResults, plannedPackages,
|
|
25
29
|
} from './shared/run-ledger.mjs'
|
|
26
30
|
import { routeBoundaries } from './shared/security-boundaries.mjs'
|
|
27
|
-
import { createPlanFromRoutes } from './shared/harness-plan.mjs'
|
|
31
|
+
import { createPlanFromRoutes, channelForProvider } from './shared/harness-plan.mjs'
|
|
28
32
|
import { applyManualPackagePlan } from './shared/manual-plan.mjs'
|
|
29
33
|
import { parsePackageAssignments, packagePlanKey } from './shared/package-assignments.mjs'
|
|
30
34
|
import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
|
|
@@ -377,13 +381,111 @@ function budgetSettings(config) {
|
|
|
377
381
|
}
|
|
378
382
|
}
|
|
379
383
|
|
|
380
|
-
function budgetFor(config, saved, estimateUsd) {
|
|
384
|
+
function budgetFor(config, saved, estimateUsd, { checkEstimate = true, apiExposure = true } = {}) {
|
|
381
385
|
const settings = budgetSettings(config)
|
|
382
|
-
if ((saved.storageAvailable === false || saved.historyVerified === false) && (settings.dailyLimitUsd > 0 || settings.monthlyLimitUsd > 0)) {
|
|
386
|
+
if (apiExposure && (saved.storageAvailable === false || saved.historyVerified === false) && (settings.dailyLimitUsd > 0 || settings.monthlyLimitUsd > 0)) {
|
|
383
387
|
throw new Error('无法读取费用历史,不能核验日/月预算;请恢复存储连接后再执行。')
|
|
384
388
|
}
|
|
385
|
-
const
|
|
386
|
-
|
|
389
|
+
const recorded = spending(saved.runs, undefined, saved.archivedSpending)
|
|
390
|
+
const reserved = reservationSpending(saved)
|
|
391
|
+
const spent = { ...recorded, today: recorded.today + reserved.today, month: recorded.month + reserved.month,
|
|
392
|
+
unknownToday: recorded.unknownToday + reserved.unknownToday, unknownMonth: recorded.unknownMonth + reserved.unknownMonth }
|
|
393
|
+
const check = budgetCheck({ estimateUsd, spent, ...settings })
|
|
394
|
+
if (!apiExposure) { check.exceeded = null; check.message = '订阅按用量单独统计,不占用 API 金额预算。' }
|
|
395
|
+
if (apiExposure && check.limited && (checkEstimate && !check.estimateKnown || settings.dailyLimitUsd > 0 && spent.unknownToday > 0
|
|
396
|
+
|| settings.monthlyLimitUsd > 0 && spent.unknownMonth > 0) && !check.exceeded) {
|
|
397
|
+
check.exceeded = 'unknown'
|
|
398
|
+
check.message = '存在未核对费用或缺少单价,无法核验预算;请先核对账单或明确确认继续。'
|
|
399
|
+
}
|
|
400
|
+
return { ...check, spent, reserved, ...settings, apiExposure }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function pausedExecutionBudget(lifecycle) {
|
|
404
|
+
if (['history-incomplete', 'active-run', 'reservation-capacity'].includes(lifecycle.reason)) {
|
|
405
|
+
throw new Error(lifecycle.reason === 'active-run' ? '该运行仍在执行或费用待核对,不能重复启动。'
|
|
406
|
+
: lifecycle.reason === 'history-incomplete' ? '费用历史不完整,不能核验执行预算。' : '待核对运行过多,请先核对执行记录和费用。')
|
|
407
|
+
}
|
|
408
|
+
return { ...lifecycle.budget, paused: true, exceeded: lifecycle.budget?.exceeded ?? 'unknown' }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function accountingContext(billing = null, routes = [], officialCli = false) {
|
|
412
|
+
return { billing: billing ? { ...billing, routes } : null, officialCli,
|
|
413
|
+
loginBillingFor: id => subscriptionLoginOf(healthCache.loginState(id)), apiKeyFor: id => apiKeyEnvPresent(id) }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function packageEstimates(plan, task) {
|
|
417
|
+
if (plan.accounting) return accountingEstimates(plan)
|
|
418
|
+
return plannedPackages(plan, task).map(item => ({ id: item.id, estimatedCostUsd: item.estimatedCost ?? null }))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Activate API money accounting only at the actual API dispatch boundary. */
|
|
422
|
+
async function reserveChangedApiRoute(lifecycle, expected, route, task, config, confirmOverBudget) {
|
|
423
|
+
const id = lifecycle.activePackageId()
|
|
424
|
+
if (!id) throw new Error('缺少执行检查点,不能启动 API 路线。')
|
|
425
|
+
const previous = lifecycle.results().find(item => item.id === id)
|
|
426
|
+
const held = lifecycle.reservationFor(id)
|
|
427
|
+
const unchanged = expected?.provider === route.provider && expected?.model === route.model
|
|
428
|
+
const saved = await routingSavedState(config)
|
|
429
|
+
const estimate = held?.billing === 'api' && unchanged ? held.estimatedCostUsd
|
|
430
|
+
: createPlanFromRoutes(task, [route], { ...planDataOptions(saved, config),
|
|
431
|
+
mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost
|
|
432
|
+
const reserved = await lifecycle.reserveExtra(id, estimate, { confirmOverBudget: confirmOverBudget === true })
|
|
433
|
+
if (!reserved.accepted) return { ok: false, notStarted: true,
|
|
434
|
+
subscriptionAttempts: previous?.subscriptionAttempts ?? [],
|
|
435
|
+
error: `实际 API 路线预算未确认,已停止调用。${reserved.budget.message}` }
|
|
436
|
+
if (held?.billing === 'subscription' || previous?.billing === 'subscription') {
|
|
437
|
+
await lifecycle.partialResult({ id, provider: route.provider, model: route.model, billing: 'api',
|
|
438
|
+
channel: 'harness-llm', ok: false, notStarted: true, answer: '', usage: null,
|
|
439
|
+
subscriptionAttempts: previous?.subscriptionAttempts ?? [] }, { checkBudget: true })
|
|
440
|
+
}
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function checkpointSubscription(lifecycle, event) {
|
|
445
|
+
await lifecycle.partialResult({ ...event, billing: 'subscription', channel: event.channel ?? 'harness-llm',
|
|
446
|
+
ok: false, answer: '', subscriptionAttempts: event.subscriptionAttempts ?? [event] })
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** A changed budget stops before dispatch; storage failures retain their hold. */
|
|
450
|
+
async function stopExecutionLifecycle(lifecycle, error, run = lifecycle.current('interrupted')) {
|
|
451
|
+
if (error?.code === 'ROUTER_BUDGET_CHANGED') {
|
|
452
|
+
run.status = 'paused-budget'
|
|
453
|
+
run.error = error.message
|
|
454
|
+
run.finishedAt = Date.now()
|
|
455
|
+
try { await lifecycle.finish(run); error.runId = run.id; return true }
|
|
456
|
+
catch { await lifecycle.interrupt(run); return false }
|
|
457
|
+
}
|
|
458
|
+
await lifecycle.interrupt(run)
|
|
459
|
+
return false
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The independent-opinion tool uses the same durable API budget boundary. */
|
|
463
|
+
export async function executeBudgetedConsult(ctx, route, task, config = {}, { signal, limit = 12_000, confirmOverBudget = false, routes = [] } = {}) {
|
|
464
|
+
const problem = taskTextProblem(text(task))
|
|
465
|
+
if (problem) throw new Error(problem)
|
|
466
|
+
const catalog = [route, ...routes.filter(item => item.provider !== route.provider || item.model !== route.model)]
|
|
467
|
+
const funding = initialFunding(route, { billing: { routes: catalog }, executionChannel: 'harness-llm' })
|
|
468
|
+
route = catalog.find(item => item.provider === funding.route?.provider && item.model === funding.route?.model) ?? funding.route
|
|
469
|
+
const plan = createPlanFromRoutes(task, [route], { mode: 'direct', directProvider: route.provider, directModel: route.model })
|
|
470
|
+
plan.accounting = { apiExposure: funding.billing === 'api', apiEstimatedCostUsd: funding.billing === 'subscription' ? 0 : plan.estimatedCost,
|
|
471
|
+
packages: [{ id: 'direct', billing: funding.billing, apiEstimatedCostUsd: funding.billing === 'subscription' ? 0 : plan.estimatedCost }] }
|
|
472
|
+
const id = randomUUID(), at = Date.now()
|
|
473
|
+
const record = (status, results) => buildRunRecord({ id, createdAt: at, finishedAt: Date.now(), task, plan,
|
|
474
|
+
execution: { status, packages: results }, workspace: process.cwd(), pricingFor: () => route.pricing ?? null, billingFor: () => funding.billing })
|
|
475
|
+
const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: ({ status, results }) => record(status, results),
|
|
476
|
+
packageEstimates: packageEstimates(plan, task), settings: budgetSettings(config), confirmOverBudget })
|
|
477
|
+
if (!lifecycle.accepted) return { status: 'paused-budget', paused: true, budget: pausedExecutionBudget(lifecycle), ok: false }
|
|
478
|
+
let result
|
|
479
|
+
try {
|
|
480
|
+
await lifecycle.onCheckpointStart({ id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm' })
|
|
481
|
+
result = await consultConfiguredModel(ctx, route, task, limit, signal)
|
|
482
|
+
const receipt = { ...result, id: 'direct', provider: route.provider, model: route.model, channel: 'harness-llm', billing: funding.billing, finishedAt: Date.now() }
|
|
483
|
+
if (funding.billing === 'subscription') receipt.subscriptionAttempts = [{ provider: route.provider, model: route.model,
|
|
484
|
+
usage: result.usage ?? null, usageIncomplete: result.usageIncomplete === true, notStarted: result.notStarted === true, finishedAt: receipt.finishedAt }]
|
|
485
|
+
await lifecycle.onCheckpointResult(receipt)
|
|
486
|
+
await lifecycle.finish(record(result.cancelled ? 'cancelled' : result.ok ? 'completed' : 'failed', lifecycle.results()))
|
|
487
|
+
} catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
|
|
488
|
+
return { ...result, billing: funding.billing, accounting: plan.accounting, runId: id }
|
|
387
489
|
}
|
|
388
490
|
|
|
389
491
|
/**
|
|
@@ -392,6 +494,11 @@ function budgetFor(config, saved, estimateUsd) {
|
|
|
392
494
|
*/
|
|
393
495
|
export function billingForResult(result) {
|
|
394
496
|
const toolId = result?.toolId ?? null
|
|
497
|
+
if (result?.billing === 'unknown') return 'unknown'
|
|
498
|
+
const login = toolId ? subscriptionLoginOf(healthCache.loginState(toolId)) : 'unknown'
|
|
499
|
+
if (result?.channel === 'official-cli' && !result.billing
|
|
500
|
+
&& !['configured-api-key', 'process-environment'].includes(result.credentialSource)
|
|
501
|
+
&& !apiKeyEnvPresent(toolId) && !['api-key', 'subscription'].includes(login)) return 'unknown'
|
|
395
502
|
return billingOf(result, {
|
|
396
503
|
loginBilling: toolId ? healthCache.loginState(toolId)?.billing ?? null : null,
|
|
397
504
|
apiKeyPresent: toolId ? apiKeyEnvPresent(toolId) : false,
|
|
@@ -496,14 +603,19 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
|
|
|
496
603
|
const loggedOut = options.skipToolProbe === true
|
|
497
604
|
? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
|
|
498
605
|
: loggedOutIds(await currentHealth())
|
|
606
|
+
const planningBilling = await billingContext(config, options)
|
|
607
|
+
const context = accountingContext(planningBilling, availableRoutes)
|
|
608
|
+
const fundingPrices = planningFundingPrices(availableRoutes, context, route => channelForProvider(route.provider, installed, [...executable], route, loggedOut).kind)
|
|
499
609
|
const plan = createPlanFromRoutes(taskText, availableRoutes, {
|
|
500
|
-
...planDataOptions(saved, config),
|
|
610
|
+
...planDataOptions(saved, config), pricing: fundingPrices.pricing,
|
|
501
611
|
mode, budgetUsd, installedToolIds: installed,
|
|
502
612
|
runnableToolIds: installed.filter(id => executable.has(id)),
|
|
503
613
|
preset: routingPresetOf(config, options.preset),
|
|
504
614
|
loggedOutToolIds: loggedOut,
|
|
505
615
|
})
|
|
506
|
-
|
|
616
|
+
plan.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes
|
|
617
|
+
attachPlanAccounting(plan, taskText, availableRoutes, context)
|
|
618
|
+
return { ...plan, packagePlanKey: packagePlanKey(plan, taskText), budgetStatus: budgetFor(config, saved, plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure }) }
|
|
507
619
|
}
|
|
508
620
|
|
|
509
621
|
/** One bounded, independent call through the same official LLM service. */
|
|
@@ -674,7 +786,7 @@ function reviewScore(answer) {
|
|
|
674
786
|
* 质量回路: a stronger configured model spot-checks answers from cheaper
|
|
675
787
|
* routes. `sample` reviews a random share; `always` reviews every cheaper answer.
|
|
676
788
|
*/
|
|
677
|
-
export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random } = {}) {
|
|
789
|
+
export async function reviewRunPackages(ctx, run, routes, config, { signal, random = Math.random, lifecycle } = {}) {
|
|
678
790
|
const mode = valueOf(config, 'reviewMode', 'off')
|
|
679
791
|
if (mode !== 'sample' && mode !== 'always') return []
|
|
680
792
|
const rate = Math.min(1, Math.max(0, finiteNumber(valueOf(config, 'reviewSampleRate', 0.2), 0.2)))
|
|
@@ -696,6 +808,24 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
|
|
|
696
808
|
`工作包:${item.name}\n${item.objective.slice(0, 1_500)}`,
|
|
697
809
|
`待审结果:\n${item.answer.slice(0, 3_500)}`,
|
|
698
810
|
].join('\n\n')
|
|
811
|
+
const reviewId = `review:${item.id}`
|
|
812
|
+
let pendingReview = null
|
|
813
|
+
if (lifecycle) {
|
|
814
|
+
const estimate = createPlanFromRoutes(prompt, [reviewer], { mode: 'direct', directProvider: reviewer.provider,
|
|
815
|
+
directModel: reviewer.model }).estimatedCost
|
|
816
|
+
const reserved = await lifecycle.reserveExtra(reviewId, estimate)
|
|
817
|
+
if (!reserved.accepted) { item.review = { score: null, summary: '预算未确认,已跳过额外审阅。', at: Date.now() }; continue }
|
|
818
|
+
pendingReview = { id: reviewId, packageId: item.id, provider: reviewer.provider, model: reviewer.model,
|
|
819
|
+
status: 'running', inFlight: true, ran: false, costUsd: null, billing: 'api', finishedAt: Date.now() }
|
|
820
|
+
run.reviews.push(pendingReview)
|
|
821
|
+
try { await lifecycle.saveRun(run, { startedIds: [reviewId] }) }
|
|
822
|
+
catch (error) {
|
|
823
|
+
if (error.code !== 'ROUTER_BUDGET_CHANGED') throw error
|
|
824
|
+
run.reviews = run.reviews.filter(entry => entry !== pendingReview)
|
|
825
|
+
item.review = { score: null, summary: '预算已变化,已跳过额外审阅。', at: Date.now() }
|
|
826
|
+
break
|
|
827
|
+
}
|
|
828
|
+
}
|
|
699
829
|
try {
|
|
700
830
|
const result = await consultConfiguredModel(ctx, reviewer, prompt, 1_200, signal)
|
|
701
831
|
const score = result.ok ? reviewScore(result.answer) : null
|
|
@@ -707,9 +837,16 @@ export async function reviewRunPackages(ctx, run, routes, config, { signal, rand
|
|
|
707
837
|
}
|
|
708
838
|
const cost = actualCost(result, reviewer.pricing ?? null)
|
|
709
839
|
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 }
|
|
710
|
-
|
|
840
|
+
if (pendingReview) Object.assign(pendingReview, entry, { status: 'completed', inFlight: false })
|
|
841
|
+
else run.reviews.push(entry)
|
|
711
842
|
reviews.push({ ...entry, score })
|
|
843
|
+
if (lifecycle) await lifecycle.saveRun(run, { completedIds: [reviewId] })
|
|
712
844
|
} catch (error) {
|
|
845
|
+
if (error.code === 'ROUTER_CHECKPOINT_FAILED') throw error
|
|
846
|
+
if (pendingReview) {
|
|
847
|
+
Object.assign(pendingReview, { status: 'failed', inFlight: false, ran: true, costUsd: null, finishedAt: Date.now() })
|
|
848
|
+
await lifecycle.saveRun(run, { completedIds: [reviewId] })
|
|
849
|
+
}
|
|
713
850
|
if (signal?.aborted) break
|
|
714
851
|
item.review = { provider: reviewer.provider, model: reviewer.model, score: null, summary: `审阅失败:${errorText(error).slice(0, 200)}`, at: Date.now() }
|
|
715
852
|
}
|
|
@@ -724,6 +861,9 @@ async function reportRunProgress(options, event) {
|
|
|
724
861
|
|
|
725
862
|
function executionHooks(ctx, options) {
|
|
726
863
|
return {
|
|
864
|
+
onCheckpointStart: options.onCheckpointStart,
|
|
865
|
+
onCheckpointResult: options.onCheckpointResult,
|
|
866
|
+
onSubscriptionResult: options.onSubscriptionResult,
|
|
727
867
|
skipOfficial: options.skipOfficial ?? (request => healthCache.skipReason(request)),
|
|
728
868
|
onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
|
|
729
869
|
onPackageStart: async item => {
|
|
@@ -785,32 +925,38 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
|
|
|
785
925
|
? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
|
|
786
926
|
: loggedOutIds(await currentHealth())
|
|
787
927
|
const preset = routingPresetOf(config, options.preset)
|
|
788
|
-
const
|
|
928
|
+
const planningBilling = await billingContext(config, options)
|
|
929
|
+
const fundingPrices = planningFundingPrices(routes, accountingContext(planningBilling, routes),
|
|
930
|
+
route => channelForProvider(route.provider, installed, runnableToolIds, route, loggedOut).kind)
|
|
931
|
+
const buildWith = (presetId, budgetUsd) => direct
|
|
789
932
|
? createPlanFromRoutes(taskText, routes, {
|
|
790
|
-
...planDataOptions(saved, config),
|
|
933
|
+
...planDataOptions(saved, config), pricing: fundingPrices.pricing,
|
|
791
934
|
mode: 'direct', directProvider: options.provider, directModel: options.model,
|
|
792
935
|
installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
793
936
|
})
|
|
794
937
|
: createPlanFromRoutes(taskText, routes, {
|
|
795
|
-
...planDataOptions(saved, config),
|
|
938
|
+
...planDataOptions(saved, config), pricing: fundingPrices.pricing,
|
|
796
939
|
mode: options.planMode === 'team' ? 'team' : 'single',
|
|
797
940
|
budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
|
|
798
941
|
})
|
|
942
|
+
const planWith = (...args) => { const result = buildWith(...args); result.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes; return result }
|
|
799
943
|
let plan = planWith(preset, options.budgetUsd)
|
|
800
944
|
if (manual) {
|
|
801
945
|
plan = applyManualPackagePlan(plan, { task: taskText, packageAssignments: options.packageAssignments,
|
|
802
946
|
packagePlanKey: options.packagePlanKey }, routes, planDataOptions(saved, config))
|
|
803
947
|
await validateManualTools(ctx, plan.manualAssignments, { ...options, installedToolIds: installed, readiness })
|
|
804
948
|
}
|
|
805
|
-
|
|
949
|
+
attachPlanAccounting(plan, taskText, routes, accountingContext(planningBilling, routes))
|
|
950
|
+
let budget = budgetFor(config, saved, plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure })
|
|
806
951
|
const perRunLimit = Number(options.budgetUsd) || 0
|
|
807
|
-
if (manual && perRunLimit > 0 && (plan.
|
|
808
|
-
budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.
|
|
809
|
-
message: plan.
|
|
952
|
+
if (manual && perRunLimit > 0 && (plan.accounting.apiEstimatedCostUsd === null || plan.accounting.apiEstimatedCostUsd > perRunLimit)) {
|
|
953
|
+
budget = { ...budget, exceeded: budget.exceeded || 'run', estimateUsd: plan.accounting.apiEstimatedCostUsd,
|
|
954
|
+
message: plan.accounting.apiEstimatedCostUsd === null ? '手动分配包含费用未知的目标,无法确认满足本次预算。' : '手动分配的预估费用超出本次预算。' }
|
|
810
955
|
}
|
|
811
956
|
if (budget.exceeded && budget.action === 'downgrade' && !direct && !manual) {
|
|
812
957
|
const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
|
|
813
|
-
|
|
958
|
+
attachPlanAccounting(cheaper, taskText, routes, accountingContext(planningBilling, routes))
|
|
959
|
+
const recheck = budgetFor(config, saved, cheaper.accounting.apiEstimatedCostUsd, { apiExposure: cheaper.accounting.apiExposure })
|
|
814
960
|
if (!recheck.exceeded) {
|
|
815
961
|
budget = { ...recheck, downgraded: true, downgradedFrom: preset,
|
|
816
962
|
message: `原方案${budget.message} 已自动降级为“省钱优先”方案(预估 ${cheaper.estimatedCost === null ? '价格待配置' : formatUsd(cheaper.estimatedCost)})。` }
|
|
@@ -861,7 +1007,11 @@ function apiUpgradeEligible(route, routes) {
|
|
|
861
1007
|
|
|
862
1008
|
/** Route, or honor one explicit model, then run each package through its adapter. */
|
|
863
1009
|
export async function executeConfiguredAssignment(ctx, task, config = {}, options = {}) {
|
|
864
|
-
const { taskText, direct, plan, budget, routes, preset } = options.preparedAssignment ?? await planAssignment(ctx, task, config, options)
|
|
1010
|
+
const { taskText, direct, plan, budget: previewBudget, routes, preset } = options.preparedAssignment ?? await planAssignment(ctx, task, config, options)
|
|
1011
|
+
const billing = await billingContext(config, options)
|
|
1012
|
+
attachPlanAccounting(plan, taskText, routes, accountingContext(billing, routes))
|
|
1013
|
+
const budget = { ...previewBudget, ...budgetFor(config, await savedState(), plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure }) }
|
|
1014
|
+
if (plan.accounting.apiExposure && previewBudget.exceeded === 'run') Object.assign(budget, { exceeded: 'run', message: previewBudget.message })
|
|
865
1015
|
if (budget.exceeded && options.confirmOverBudget !== true) {
|
|
866
1016
|
return {
|
|
867
1017
|
plan, execution: null, paused: true,
|
|
@@ -874,12 +1024,26 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
|
|
|
874
1024
|
finished.push(result)
|
|
875
1025
|
if (typeof options.onPackage === 'function') await options.onPackage(result)
|
|
876
1026
|
} })
|
|
877
|
-
const billing = await billingContext(config, options)
|
|
878
1027
|
const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
|
|
879
1028
|
const startedAt = Date.now()
|
|
1029
|
+
const runId = randomUUID()
|
|
1030
|
+
const makeRecord = execution => buildRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(),
|
|
1031
|
+
task: taskText, plan, execution, preset: plan.preset ?? preset, workspace: options.workspace ?? '',
|
|
1032
|
+
pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
|
|
1033
|
+
budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true,
|
|
1034
|
+
confirmed: options.confirmOverBudget === true } })
|
|
1035
|
+
const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(),
|
|
1036
|
+
packageEstimates: packageEstimates(plan, taskText), settings: budgetSettings(config),
|
|
1037
|
+
confirmOverBudget: options.confirmOverBudget === true,
|
|
1038
|
+
buildRun: ({ status, results }) => makeRecord({ status, packages: results }) })
|
|
1039
|
+
if (!lifecycle.accepted) return { plan, execution: null, paused: true, budget: pausedExecutionBudget(lifecycle) }
|
|
880
1040
|
const gate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
|
|
881
1041
|
const gatedApi = async ({ route, task: packageTask, signal, subscription }) => {
|
|
882
1042
|
if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, signal)
|
|
1043
|
+
const plannedPackage = plannedPackages(plan, taskText).find(item => item.id === lifecycle.activePackageId())
|
|
1044
|
+
const denied = await reserveChangedApiRoute(lifecycle, { provider: plannedPackage?.recommendedProvider,
|
|
1045
|
+
model: plannedPackage?.recommendedModel }, route, packageTask, config, options.confirmOverBudget)
|
|
1046
|
+
if (denied) return denied
|
|
883
1047
|
const quality = routeQuality(route)
|
|
884
1048
|
const alternatives = gate && !direct && route.manualPinned !== true && apiUpgradeEligible(route, routes)
|
|
885
1049
|
&& valueOf(config, 'qualityEscalationEnabled', false) === true && quality !== null
|
|
@@ -906,10 +1070,23 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
|
|
|
906
1070
|
&& (result.blocked || result.notStarted || billingForResult(result) === 'subscription') ? { costUsd: 0 }
|
|
907
1071
|
: actualCost(result, routePricing(routes, result))), ...attempts.map(result => actualCost(result, result.pricingSnapshot))]
|
|
908
1072
|
const pending = plan.costBreakdown.slice(finished.length + 1).map(item => item.estimatedCost)
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1073
|
+
const unknownProjection = costs.some(item => item.costUsd === null) || pending.some(value => !Number.isFinite(value))
|
|
1074
|
+
if (unknownProjection && constrained) return false
|
|
1075
|
+
const projected = unknownProjection ? null
|
|
1076
|
+
: costs.reduce((sum, item) => sum + item.costUsd, estimate) + pending.reduce((sum, value) => sum + value, 0)
|
|
1077
|
+
if (!unknownProjection && (!Number.isFinite(projected) || perRunLimit > 0 && projected > perRunLimit)) return false
|
|
1078
|
+
const id = lifecycle.activePackageId()
|
|
1079
|
+
if (!id) return false
|
|
1080
|
+
const subscriptionAttempts = lifecycle.results().find(item => item.id === id)?.subscriptionAttempts ?? []
|
|
1081
|
+
await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, subscriptionAttempts, qualityAttempts: attempts })
|
|
1082
|
+
if (!(await lifecycle.reserveExtra(id, estimate)).accepted) return false
|
|
1083
|
+
// The extra request can be charged before its receipt arrives. Persist
|
|
1084
|
+
// both the first receipt and the unknown second attempt before dispatch.
|
|
1085
|
+
await lifecycle.partialResult({ ...attempts.at(-1), id, ok: false, subscriptionAttempts, qualityAttempts: [...attempts, {
|
|
1086
|
+
provider: to.provider, model: to.model, channel: 'harness-llm', ok: false,
|
|
1087
|
+
usage: null, usageIncomplete: true, pricingSnapshot: to.pricing ?? null, finishedAt: Date.now(),
|
|
1088
|
+
}] }, { checkBudget: true })
|
|
1089
|
+
return true
|
|
913
1090
|
},
|
|
914
1091
|
})
|
|
915
1092
|
}
|
|
@@ -922,40 +1099,50 @@ export async function executeConfiguredAssignment(ctx, task, config = {}, option
|
|
|
922
1099
|
workspace: options.workspace,
|
|
923
1100
|
signal: options.signal,
|
|
924
1101
|
...hooks,
|
|
925
|
-
|
|
1102
|
+
onCheckpointStart: item => lifecycle.onCheckpointStart(item),
|
|
1103
|
+
onCheckpointResult: result => lifecycle.onCheckpointResult(result),
|
|
1104
|
+
onSubscriptionResult: event => checkpointSubscription(lifecycle, event),
|
|
1105
|
+
billing: billing ? { ...billing, routes } : null,
|
|
926
1106
|
apiFallback: gatedApi,
|
|
927
1107
|
})
|
|
928
1108
|
} catch (error) {
|
|
929
1109
|
// Steps that already ran (and may have been paid for) are always recorded with their spend.
|
|
930
1110
|
const message = String(error?.message ?? error)
|
|
931
|
-
const
|
|
1111
|
+
const receipts = new Map([...finished, ...lifecycle.results(), ...(error.execution?.packages ?? [])].map(item => [item.id, item]))
|
|
1112
|
+
const partial = { status: options.signal?.aborted ? 'cancelled' : 'failed', packages: [...receipts.values()], aggregate: '', error: message }
|
|
932
1113
|
const run = buildRunRecord({
|
|
933
|
-
id:
|
|
1114
|
+
id: runId, createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution: partial,
|
|
934
1115
|
preset: plan.preset ?? preset, workspace: options.workspace ?? '',
|
|
935
1116
|
pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
|
|
936
1117
|
budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
|
|
937
1118
|
})
|
|
938
1119
|
run.error = message.slice(0, 2_000)
|
|
939
1120
|
let stored = true
|
|
940
|
-
try {
|
|
1121
|
+
try {
|
|
1122
|
+
if (error.code === 'ROUTER_BUDGET_CHANGED') stored = await stopExecutionLifecycle(lifecycle, error, run)
|
|
1123
|
+
else if (error.code === 'ROUTER_CHECKPOINT_FAILED') { await lifecycle.interrupt(run); stored = false }
|
|
1124
|
+
else await lifecycle.finish(run)
|
|
1125
|
+
} catch { stored = false; await lifecycle.interrupt(run) }
|
|
941
1126
|
const wrapped = new Error(`${message}${finished.length ? `(已完成 ${finished.length} 个步骤,${stored ? `已记录到运行 ${run.id},费用计入预算` : '但运行记录保存失败'})` : ''}`)
|
|
942
1127
|
wrapped.runId = stored ? run.id : null
|
|
943
1128
|
wrapped.cause = error
|
|
944
1129
|
throw wrapped
|
|
945
1130
|
}
|
|
946
1131
|
const run = buildRunRecord({
|
|
947
|
-
id:
|
|
1132
|
+
id: runId, createdAt: startedAt, finishedAt: Date.now(), task: taskText, plan, execution,
|
|
948
1133
|
preset: plan.preset ?? preset, workspace: options.workspace ?? '',
|
|
949
1134
|
pricingFor: item => routePricing(routes, item),
|
|
950
1135
|
billingFor: billingForResult,
|
|
951
1136
|
budget: { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: budget.downgraded === true, confirmed: budget.exceeded ? true : undefined },
|
|
952
1137
|
})
|
|
953
1138
|
await reportRunProgress(options, { type: 'stage', stage: 'reviewing' })
|
|
954
|
-
|
|
1139
|
+
let reviews
|
|
1140
|
+
try { reviews = await reviewRunPackages(ctx, run, routes, config, { signal: options.signal, random: options.random, lifecycle }) }
|
|
1141
|
+
catch (error) { await lifecycle.interrupt(run); throw error }
|
|
955
1142
|
if (options.signal?.aborted) { run.status = 'cancelled'; execution.status = 'cancelled' }
|
|
956
1143
|
await reportRunProgress(options, { type: 'stage', stage: 'saving' })
|
|
957
1144
|
let stored = true
|
|
958
|
-
try { await
|
|
1145
|
+
try { await lifecycle.finish(run) } catch { stored = false; await lifecycle.interrupt(run) }
|
|
959
1146
|
const awaiting = pausedSteps(execution)
|
|
960
1147
|
return { plan, execution, budget, runId: run.id, run, reviews, stored, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
|
|
961
1148
|
}
|
|
@@ -1022,6 +1209,7 @@ function rerunEstimates(run, ids, targetId, override, routes, dataOptions) {
|
|
|
1022
1209
|
|
|
1023
1210
|
export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, confirmUnsandboxed = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
|
|
1024
1211
|
const { run, saved } = await storedRun(runId)
|
|
1212
|
+
if (saved.budgetReservations?.[run.id]) throw new Error('该运行仍在执行或费用待核对,请先核对原任务与厂商账单,不能直接重跑。')
|
|
1025
1213
|
const target = run.packages.find(item => item.id === text(packageId))
|
|
1026
1214
|
if (!target) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
|
|
1027
1215
|
const choice = text(subscriptionChoice) || null
|
|
@@ -1061,14 +1249,24 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
|
|
|
1061
1249
|
const retryIds = new Set([target.id, ...downstreamPackageIds(run.packages, target.id)
|
|
1062
1250
|
.filter(id => run.packages.find(item => item.id === id)?.ok !== true)])
|
|
1063
1251
|
const estimates = rerunEstimates(run, retryIds, target.id, override, routes, planDataOptions(refreshed, config))
|
|
1064
|
-
const
|
|
1252
|
+
const retryBilling = await billingContext(config, options)
|
|
1253
|
+
const retryPlan = teamPlanFromRun(run, [...retryIds], override, target.id, estimates.byId)
|
|
1254
|
+
if (run.kind !== 'team') for (const item of retryPlan.team.workPackages) {
|
|
1255
|
+
const previous = run.packages.find(entry => entry.id === item.id)
|
|
1256
|
+
if (!override || item.id !== target.id) { item.recommendedProvider = previous.provider ?? previous.recommendedProvider; item.recommendedModel = previous.model ?? previous.recommendedModel }
|
|
1257
|
+
}
|
|
1258
|
+
const retryContext = accountingContext(retryBilling, routes, run.kind === 'team')
|
|
1259
|
+
retryContext.billingChoices = choice ? { [target.id]: choice } : {}
|
|
1260
|
+
attachPlanAccounting(retryPlan, run.task, routes, retryContext)
|
|
1261
|
+
estimates.reservations = accountingEstimates(retryPlan)
|
|
1262
|
+
const budget = budgetFor(config, saved, retryPlan.accounting.apiEstimatedCostUsd, { apiExposure: retryPlan.accounting.apiExposure })
|
|
1065
1263
|
if (budget.exceeded && confirmOverBudget !== true) {
|
|
1066
1264
|
return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
|
|
1067
1265
|
}
|
|
1068
1266
|
const cwd = text(workspace) || run.workspace
|
|
1069
1267
|
if (!cwd || !(await fileExists(cwd))) throw new Error(`原运行的工作区 ${cwd || '(未记录)'} 不可用,无法重跑该步骤;请恢复该目录,或在原工作区的会话中调用 model_router_rerun_step。`)
|
|
1070
1268
|
if (run.kind === 'team') return rerunTeamStep(ctx, { run, target, override, routes, budget,
|
|
1071
|
-
ids: [...retryIds], estimates: estimates.byId, cwd, root: text(root) || cwd, signal, options, config })
|
|
1269
|
+
ids: [...retryIds], estimates: estimates.byId, reservations: estimates.reservations, confirmOverBudget, cwd, root: text(root) || cwd, signal, options, config })
|
|
1072
1270
|
const manualRetries = Object.fromEntries(run.packages.filter(item => retryIds.has(item.id) && item.manualAssignment)
|
|
1073
1271
|
.map(item => [item.id, item.id === target.id && override ? { kind: 'api', ...override } : item.manualAssignment]))
|
|
1074
1272
|
if (Object.keys(manualRetries).length) {
|
|
@@ -1088,7 +1286,18 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
|
|
|
1088
1286
|
}
|
|
1089
1287
|
const limit = boundedInteger(valueOf(config, 'maxConsultOutputChars', 12_000), 12_000, 500, 50_000)
|
|
1090
1288
|
const retryGate = parseQualityGateJson(valueOf(config, 'qualityGateJson', ''))
|
|
1091
|
-
const
|
|
1289
|
+
const buildRetry = ({ status, results }) => {
|
|
1290
|
+
const next = structuredClone(run)
|
|
1291
|
+
mergeRerun(next, { status, packages: results }, { rerunIds: [...retryIds], pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
|
|
1292
|
+
for (const item of next.packages) if (retryIds.has(item.id)) item.estimatedCost = estimates.byId.get(item.id) ?? null
|
|
1293
|
+
return next
|
|
1294
|
+
}
|
|
1295
|
+
const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
|
|
1296
|
+
packageEstimates: estimates.reservations,
|
|
1297
|
+
settings: budgetSettings(config), confirmOverBudget })
|
|
1298
|
+
if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
|
|
1299
|
+
let execution
|
|
1300
|
+
try { execution = await rerunAssignmentPackage({
|
|
1092
1301
|
task: run.task,
|
|
1093
1302
|
packages: run.packages.map(item => ({
|
|
1094
1303
|
id: item.id, name: item.name, objective: item.objective, dependsOn: item.dependsOn,
|
|
@@ -1108,17 +1317,22 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
|
|
|
1108
1317
|
signal,
|
|
1109
1318
|
...(choice ? { subscriptionChoice: choice } : {}),
|
|
1110
1319
|
...executionHooks(ctx, options),
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1320
|
+
onCheckpointStart: item => lifecycle.onCheckpointStart(item),
|
|
1321
|
+
onCheckpointResult: result => lifecycle.onCheckpointResult(result),
|
|
1322
|
+
onSubscriptionResult: event => checkpointSubscription(lifecycle, event),
|
|
1323
|
+
billing: retryBilling,
|
|
1324
|
+
apiFallback: async ({ route, task: packageTask, signal: callSignal, subscription }) => {
|
|
1325
|
+
if (subscription) return consultConfiguredModel(ctx, route, packageTask, limit, callSignal)
|
|
1326
|
+
const id = lifecycle.activePackageId()
|
|
1327
|
+
const previous = run.packages.find(item => item.id === id)
|
|
1328
|
+
const expected = override && id === target.id ? override
|
|
1329
|
+
: { provider: previous?.provider ?? previous?.recommendedProvider, model: previous?.model ?? previous?.recommendedModel }
|
|
1330
|
+
const denied = await reserveChangedApiRoute(lifecycle, expected, route, packageTask, config, confirmOverBudget)
|
|
1331
|
+
return denied ?? consultWithQualityGate(ctx, route, packageTask, limit, callSignal, { gate: retryGate })
|
|
1332
|
+
},
|
|
1333
|
+
}) } catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
|
|
1334
|
+
const updated = buildRetry({ status: execution.status, results: execution.packages })
|
|
1335
|
+
try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
|
|
1122
1336
|
const awaiting = pausedSteps(execution)
|
|
1123
1337
|
return { run: updated, execution, budget, ...(awaiting.length ? { awaitingConfirmation: awaiting } : {}) }
|
|
1124
1338
|
}
|
|
@@ -1162,7 +1376,7 @@ function teamPlanFromRun(run, ids, override, targetId, estimates) {
|
|
|
1162
1376
|
* Read-only team retry: the failed step plus downstream steps that had not
|
|
1163
1377
|
* succeeded run through the same signed runner; earlier answers are context.
|
|
1164
1378
|
*/
|
|
1165
|
-
async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, cwd, root, signal, options, config = {} }) {
|
|
1379
|
+
async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids, estimates, reservations, confirmOverBudget, cwd, root, signal, options, config = {} }) {
|
|
1166
1380
|
const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
1167
1381
|
const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
|
|
1168
1382
|
const plan = teamPlanFromRun(run, ids, override, target.id, estimates)
|
|
@@ -1175,10 +1389,31 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
|
|
|
1175
1389
|
.map(item => ({ id: item.id, name: item.name, finalText: item.answer }))
|
|
1176
1390
|
const runTeam = options.runTeam ?? runOfficialTeam
|
|
1177
1391
|
const installed = Array.isArray(options.installedToolIds) ? options.installedToolIds : await installedToolIds()
|
|
1178
|
-
const
|
|
1392
|
+
const buildRetry = ({ status, results, workspace = {} }) => {
|
|
1393
|
+
const next = structuredClone(run)
|
|
1394
|
+
const converted = teamExecutionResults(plan, { status, results })
|
|
1395
|
+
if (override) {
|
|
1396
|
+
const item = converted.packages.find(item => item.id === target.id)
|
|
1397
|
+
if (item) item.reassigned = true
|
|
1398
|
+
}
|
|
1399
|
+
mergeRerun(next, converted, { rerunIds: ids, pricingFor: item => routePricing(routes, item), billingFor: billingForResult })
|
|
1400
|
+
for (const item of next.packages) if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
|
|
1401
|
+
next.executionEnvironment = executionEnvironment
|
|
1402
|
+
if (mode === 'workspace-write' && workspace.workspace) next.isolatedWorkspace = workspace.workspace
|
|
1403
|
+
if (workspace.baseCommit) next.baseCommit = workspace.baseCommit
|
|
1404
|
+
return next
|
|
1405
|
+
}
|
|
1406
|
+
const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), buildRun: buildRetry,
|
|
1407
|
+
packageEstimates: reservations, settings: budgetSettings(config), confirmOverBudget })
|
|
1408
|
+
if (!lifecycle.accepted) return { paused: true, run, budget: pausedExecutionBudget(lifecycle) }
|
|
1409
|
+
let execution
|
|
1410
|
+
try { execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
|
|
1179
1411
|
installedIds: installed, cliModels, signal, sandbox: ctx.sandbox, previous, onlyIds: ids,
|
|
1412
|
+
onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: result => lifecycle.onCheckpointResult(result),
|
|
1413
|
+
onWorkspace: value => lifecycle.onWorkspace(value),
|
|
1180
1414
|
...(mode === 'workspace-write' ? { seedFrom: { workspace: run.isolatedWorkspace, baseCommit: run.baseCommit } } : {}),
|
|
1181
|
-
...(options.runtime ? { runtime: options.runtime } : {}) })
|
|
1415
|
+
...(options.runtime ? { runtime: options.runtime } : {}) }) }
|
|
1416
|
+
catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
|
|
1182
1417
|
const converted = teamExecutionResults(plan, execution)
|
|
1183
1418
|
if (override) {
|
|
1184
1419
|
const entry = converted.packages.find(item => item.id === target.id)
|
|
@@ -1190,8 +1425,9 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
|
|
|
1190
1425
|
const note = await noteCliQuota(config, result.toolId, `${result.error ?? ''}\n${result.outputTail ?? ''}`)
|
|
1191
1426
|
if (note) quotaNotes.push([result.id, note])
|
|
1192
1427
|
}
|
|
1193
|
-
const updated =
|
|
1194
|
-
|
|
1428
|
+
const updated = buildRetry({ status: execution.status, results: execution.results,
|
|
1429
|
+
workspace: { workspace: execution.workspace, baseCommit: execution.baseCommit } })
|
|
1430
|
+
const current = updated
|
|
1195
1431
|
for (const item of current.packages) {
|
|
1196
1432
|
if (ids.includes(item.id)) item.estimatedCost = estimates.get(item.id) ?? null
|
|
1197
1433
|
}
|
|
@@ -1214,7 +1450,7 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, ids,
|
|
|
1214
1450
|
if (cliModels[target.id]) bindings[target.id] = cliModels[target.id]
|
|
1215
1451
|
current.cliModels = bindings
|
|
1216
1452
|
}
|
|
1217
|
-
})
|
|
1453
|
+
try { await lifecycle.finish(updated) } catch (error) { await lifecycle.interrupt(updated); throw error }
|
|
1218
1454
|
return { run: updated, execution: { ...converted, ranIds: ids, raw: execution }, budget }
|
|
1219
1455
|
}
|
|
1220
1456
|
|
|
@@ -1240,6 +1476,7 @@ async function rerunToolRun(ctx, config, { run, confirmOverBudget, confirmWrite,
|
|
|
1240
1476
|
const cwd = text(workspace) || run.workspace
|
|
1241
1477
|
if (!cwd || !(await fileExists(cwd))) throw new Error('原运行的工作区不可用,无法重跑;请在原工作区的会话中重试。')
|
|
1242
1478
|
const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root: text(root) || cwd, signal, rerunOf: run.id,
|
|
1479
|
+
confirmOverBudget,
|
|
1243
1480
|
...(options.runTask ? { runTask: options.runTask } : {}) })
|
|
1244
1481
|
const execution = { status: result.status, packages: recorded?.packages ?? [], ranIds: ['direct'], raw: result }
|
|
1245
1482
|
return { run: recorded ?? run, execution, budget: planned.budget, rerunOf: run.id, newRunId: recorded?.id ?? null }
|
|
@@ -1302,22 +1539,51 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
|
|
|
1302
1539
|
modelId = text(args.cliModel)
|
|
1303
1540
|
}
|
|
1304
1541
|
const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1542
|
+
const funding = initialFunding(route ?? {}, { ...accountingContext(null, routes, true), toolId })
|
|
1543
|
+
const apiEstimate = funding.billing === 'subscription' ? 0 : estimate
|
|
1544
|
+
const budget = budgetFor(config, await savedState(), apiEstimate, { apiExposure: funding.billing === 'api' })
|
|
1545
|
+
const resources = route ? estimateAssignedPackage(route, { id: 'direct', objective: taskText, type: 'general' }, taskText) : null
|
|
1546
|
+
const accounting = { apiEstimatedCostUsd: apiEstimate, apiExposure: funding.billing === 'api', packages: [{ id: 'direct', billing: funding.billing,
|
|
1547
|
+
apiEstimatedCostUsd: apiEstimate, estimatedUsage: { inputTokens: resources?.inputTokens ?? null, outputTokens: resources?.outputTokens ?? null } }] }
|
|
1548
|
+
return { toolId, tool, taskText, mode, modelId, route, routes, accounting, funding: funding.billing, apiEstimate, estimate: Number.isFinite(estimate) ? estimate : null, budget,
|
|
1307
1549
|
provider: route?.provider ?? '', model: route?.model ?? '', cliModel: modelId }
|
|
1308
1550
|
}
|
|
1309
1551
|
|
|
1310
1552
|
/** Run a planned tool call and record it; `rerunOf` links a rerun to the original run. */
|
|
1311
|
-
async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, runTask = runOfficialTask }) {
|
|
1553
|
+
async function runPlannedTool(ctx, config, planned, { cwd, root, signal, rerunOf = null, confirmOverBudget = false, runTask = runOfficialTask }) {
|
|
1312
1554
|
const startedAt = Date.now()
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1555
|
+
const runId = randomUUID()
|
|
1556
|
+
const makeToolRecord = (result, location = {}) => {
|
|
1557
|
+
const run = buildToolRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(),
|
|
1558
|
+
toolId: planned.toolId, toolLabel: planned.tool?.label ?? planned.toolId, task: planned.taskText,
|
|
1559
|
+
provider: planned.provider, model: planned.model, cliModel: planned.cliModel,
|
|
1560
|
+
mode: planned.mode, workspace: cwd, result, estimatedCost: planned.estimate, rerunOf,
|
|
1561
|
+
pricingFor: item => routePricing(planned.routes, item), billingFor: billingForResult, budget: planned.budget })
|
|
1562
|
+
if (location.isolatedWorkspace) run.isolatedWorkspace = location.isolatedWorkspace
|
|
1563
|
+
if (location.baseCommit) run.baseCommit = location.baseCommit
|
|
1564
|
+
return run
|
|
1565
|
+
}
|
|
1566
|
+
const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config), confirmOverBudget,
|
|
1567
|
+
packageEstimates: [{ id: 'direct', billing: planned.funding ?? 'api', estimatedCostUsd: planned.apiEstimate ?? planned.estimate ?? null }],
|
|
1568
|
+
buildRun: ({ status, results, workspace }) => makeToolRecord(results[0] ?? { status, notStarted: true }, workspace) })
|
|
1569
|
+
if (!lifecycle.accepted) {
|
|
1570
|
+
planned.budget = pausedExecutionBudget(lifecycle)
|
|
1571
|
+
return { result: { status: 'paused-budget', budget: planned.budget }, recorded: null }
|
|
1572
|
+
}
|
|
1573
|
+
let result
|
|
1574
|
+
try {
|
|
1575
|
+
result = await runTask({ toolId: planned.toolId, task: planned.taskText, modelId: planned.modelId, workspace: cwd, allowedRoot: root ?? cwd,
|
|
1576
|
+
mode: planned.mode, signal, sandbox: ctx.sandbox,
|
|
1577
|
+
onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: item => lifecycle.onCheckpointResult(item),
|
|
1578
|
+
onWorkspace: value => lifecycle.onWorkspace(value) })
|
|
1579
|
+
} catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
|
|
1580
|
+
const recorded = makeToolRecord(result, { isolatedWorkspace: result.isolatedWorkspace, baseCommit: result.baseCommit })
|
|
1581
|
+
try {
|
|
1582
|
+
if (result.status !== 'succeeded') await annotateCliQuota(recorded, config,
|
|
1583
|
+
[{ id: 'direct', toolId: planned.toolId, text: `${result.error ?? result.reason ?? ''}\n${result.outputTail ?? ''}` }])
|
|
1584
|
+
await lifecycle.finish(recorded)
|
|
1585
|
+
} catch (error) { await lifecycle.interrupt(recorded); throw error }
|
|
1586
|
+
return { result, recorded: result.status === 'unsupported' ? null : recorded }
|
|
1321
1587
|
}
|
|
1322
1588
|
|
|
1323
1589
|
/** Ledger record for model_router_tool_run. */
|
|
@@ -1356,9 +1622,13 @@ export async function rateRecordedResult({ runId, packageId, rating, expectedFin
|
|
|
1356
1622
|
|
|
1357
1623
|
/** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
|
|
1358
1624
|
export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
|
|
1625
|
+
await createBudgetReservations({ state: routerStateStore() }).recoverInterruptedExecutions()
|
|
1359
1626
|
const saved = await routingSavedState(config)
|
|
1360
1627
|
const spent = spending(saved.runs, undefined, saved.archivedSpending)
|
|
1361
1628
|
const settings = budgetSettings(config)
|
|
1629
|
+
const entries = Object.values(saved.budgetReservations ?? {})
|
|
1630
|
+
const reserved = { ...reservationSpending(saved), activeCount: entries.filter(item => item.status === 'active').length,
|
|
1631
|
+
interruptedCount: entries.filter(item => item.status === 'interrupted').length }
|
|
1362
1632
|
return {
|
|
1363
1633
|
runs: saved.runs.slice(-limit).reverse().map(run => ({
|
|
1364
1634
|
...run,
|
|
@@ -1367,7 +1637,14 @@ export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
|
|
|
1367
1637
|
.map(item => ({ ...item, answer: text(item.answer).slice(0, 1_500) })),
|
|
1368
1638
|
})),
|
|
1369
1639
|
spent,
|
|
1370
|
-
|
|
1640
|
+
subscriptionUsage: { ...subscriptionUsage(saved.runs, Date.now(), saved.archivedSubscriptionUsage), historyIncomplete: saved.subscriptionUsageIncomplete === true },
|
|
1641
|
+
reserved,
|
|
1642
|
+
reservations: entries.map(entry => ({ id: entry.id, runId: entry.runId, status: entry.status, updatedAt: entry.updatedAt,
|
|
1643
|
+
billing: entry.packages.every(item => item.billing === 'subscription') ? 'subscription' : entry.packages.some(item => item.billing === 'subscription') ? 'mixed' : 'api',
|
|
1644
|
+
unknown: entry.packages.some(item => item.billing !== 'subscription' && (item.estimatedCostUsd === null || entry.status === 'interrupted' && item.started)),
|
|
1645
|
+
remainingEstimateUsd: entry.packages.every(item => Number.isFinite(item.estimatedCostUsd))
|
|
1646
|
+
? entry.packages.reduce((sum, item) => sum + item.estimatedCostUsd, 0) : null })),
|
|
1647
|
+
budget: { ...budgetFor(config, saved, null, { checkEstimate: false }), historyVerified: saved.historyVerified !== false },
|
|
1371
1648
|
storageAvailable: saved.storageAvailable !== false,
|
|
1372
1649
|
storageNotice: saved.storageAvailable === false ? '执行记录存储暂不可用;下方费用与学习摘要尚未核验,不能当作零花费或没有反馈。'
|
|
1373
1650
|
: saved.historyVerified === false ? '此前执行记录已损坏或不完整,当前摘要只包含可读记录;不能视为完整费用历史,请从备份恢复后再核验预算。' : '',
|
|
@@ -1494,10 +1771,12 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
|
|
|
1494
1771
|
const supported = new Set(executableRoutes.map(route => toolForProvider(route.provider).id))
|
|
1495
1772
|
const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
|
|
1496
1773
|
const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
|
|
1774
|
+
const fundingPrices = planningFundingPrices(executableRoutes, accountingContext(null, executableRoutes, true))
|
|
1497
1775
|
const plan = createPlanFromRoutes(taskText, executableRoutes, {
|
|
1498
|
-
...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
|
|
1776
|
+
...planDataOptions(saved, config), pricing: fundingPrices.pricing, mode: 'team', budgetUsd, installedToolIds: installed, forceOfficialCli: true,
|
|
1499
1777
|
runnableToolIds: installed.filter(id => supported.has(id)), preset: routingPresetOf(config, args.preset),
|
|
1500
1778
|
})
|
|
1779
|
+
plan.subscriptionPricedRoutes = fundingPrices.subscriptionPricedRoutes
|
|
1501
1780
|
const bindingsText = text(args.cliModelsJson)
|
|
1502
1781
|
if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
|
|
1503
1782
|
let requestedModels = {}
|
|
@@ -1506,7 +1785,8 @@ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}
|
|
|
1506
1785
|
catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
|
|
1507
1786
|
}
|
|
1508
1787
|
const cliModels = resolveTeamCliModelBindings(plan, executableRoutes, requestedModels)
|
|
1509
|
-
|
|
1788
|
+
attachPlanAccounting(plan, taskText, executableRoutes, accountingContext(null, executableRoutes, true))
|
|
1789
|
+
const budget = budgetFor(config, await savedState(), plan.accounting.apiEstimatedCostUsd, { apiExposure: plan.accounting.apiExposure })
|
|
1510
1790
|
return { ...base, status: 'planned', plan, cliModels, budget }
|
|
1511
1791
|
}
|
|
1512
1792
|
|
|
@@ -1516,7 +1796,8 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
|
|
|
1516
1796
|
if (!planned?.plan || !['read-only', 'workspace-write'].includes(planned.mode)) throw new Error('缺少有效的官方 CLI 团队计划')
|
|
1517
1797
|
const executionEnvironment = teamExecutionEnvironment(planned.executionEnvironment, planned.mode)
|
|
1518
1798
|
const workspace = options.workspace ?? planned.workspace
|
|
1519
|
-
|
|
1799
|
+
attachPlanAccounting(planned.plan, planned.taskText, planned.routes, accountingContext(null, planned.routes, true))
|
|
1800
|
+
const budget = budgetFor(config, await savedState(), planned.plan.accounting.apiEstimatedCostUsd, { apiExposure: planned.plan.accounting.apiExposure })
|
|
1520
1801
|
const common = { plan: planned.plan, workspace, mode: planned.mode, executionEnvironment, budget }
|
|
1521
1802
|
if (budget.exceeded && options.confirmOverBudget !== true) return { ...common, status: 'paused-budget',
|
|
1522
1803
|
notice: `${budget.message} 已暂停团队执行。请向用户确认后再执行。` }
|
|
@@ -1528,15 +1809,39 @@ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options
|
|
|
1528
1809
|
reason: '官方 Harness 进程沙箱不可用,不能执行官方 CLI 团队。' }
|
|
1529
1810
|
throwIfAborted(options.signal)
|
|
1530
1811
|
const startedAt = Date.now()
|
|
1531
|
-
const
|
|
1812
|
+
const runId = randomUUID()
|
|
1813
|
+
const makeTeamRecord = (execution, location = {}) => {
|
|
1814
|
+
const run = buildTeamRunRecord({ id: runId, createdAt: startedAt, finishedAt: Date.now(), task: planned.taskText,
|
|
1815
|
+
plan: planned.plan, execution, mode: planned.mode, workspace,
|
|
1816
|
+
preset: planned.plan.preset ?? DEFAULT_ROUTING_PRESET, pricingFor: item => routePricing(planned.routes, item), billingFor: billingForResult,
|
|
1817
|
+
budget: { estimateUsd: budget.estimateUsd, confirmed: options.confirmOverBudget === true } })
|
|
1818
|
+
run.executionEnvironment = executionEnvironment
|
|
1819
|
+
run.cliModels = { ...planned.cliModels }
|
|
1820
|
+
if (planned.mode === 'workspace-write' && location.workspace) run.isolatedWorkspace = location.workspace
|
|
1821
|
+
if (location.baseCommit) run.baseCommit = location.baseCommit
|
|
1822
|
+
return run
|
|
1823
|
+
}
|
|
1824
|
+
const lifecycle = await beginExecutionLifecycle({ state: routerStateStore(), settings: budgetSettings(config),
|
|
1825
|
+
confirmOverBudget: options.confirmOverBudget === true, packageEstimates: packageEstimates(planned.plan, planned.taskText),
|
|
1826
|
+
buildRun: ({ status, results, workspace: location }) => makeTeamRecord({ status, results }, location) })
|
|
1827
|
+
if (!lifecycle.accepted) return { ...common, status: 'paused-budget', budget: pausedExecutionBudget(lifecycle) }
|
|
1828
|
+
let execution
|
|
1829
|
+
try { execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
|
|
1532
1830
|
allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
|
|
1533
1831
|
cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox,
|
|
1534
1832
|
onPackageStart: item => reportRunProgress(options, { type: 'package-start', package: item }),
|
|
1535
1833
|
onPackage: item => reportRunProgress(options, { type: 'package-complete', package: item }),
|
|
1536
|
-
onStage: stage => reportRunProgress(options, { type: 'stage', stage })
|
|
1834
|
+
onStage: stage => reportRunProgress(options, { type: 'stage', stage }),
|
|
1835
|
+
onCheckpointStart: item => lifecycle.onCheckpointStart(item), onCheckpointResult: result => lifecycle.onCheckpointResult(result),
|
|
1836
|
+
onWorkspace: value => lifecycle.onWorkspace(value) }) }
|
|
1837
|
+
catch (error) { await stopExecutionLifecycle(lifecycle, error); throw error }
|
|
1537
1838
|
await reportRunProgress(options, { type: 'stage', stage: 'saving' })
|
|
1538
|
-
const run =
|
|
1539
|
-
|
|
1839
|
+
const run = makeTeamRecord(execution, { workspace: execution.workspace, baseCommit: execution.baseCommit })
|
|
1840
|
+
try {
|
|
1841
|
+
await annotateCliQuota(run, config, (execution.results ?? []).filter(item => item.status !== 'succeeded' && item.toolId)
|
|
1842
|
+
.map(item => ({ id: item.id, toolId: item.toolId, text: `${item.error ?? ''}\n${item.outputTail ?? ''}` })))
|
|
1843
|
+
await lifecycle.finish(run)
|
|
1844
|
+
} catch (error) { await lifecycle.interrupt(run); throw error }
|
|
1540
1845
|
return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
|
|
1541
1846
|
rerunNotice: planned.mode === 'read-only'
|
|
1542
1847
|
? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
|
|
@@ -1555,7 +1860,8 @@ export function decisionSummary(plan) {
|
|
|
1555
1860
|
preset: plan?.preset ?? DEFAULT_ROUTING_PRESET,
|
|
1556
1861
|
complexity: plan?.complexity ?? null,
|
|
1557
1862
|
reason: plan?.reason ?? '',
|
|
1558
|
-
|
|
1863
|
+
accounting: plan?.accounting ?? null,
|
|
1864
|
+
estimatedCost: plan?.accounting ? plan.accounting.apiEstimatedCostUsd : plan?.estimatedCost ?? null,
|
|
1559
1865
|
packages: packages.map(item => ({
|
|
1560
1866
|
id: item.id, name: item.name, route: `${item.recommendedProvider}/${item.recommendedModel}`,
|
|
1561
1867
|
difficulty: item.difficulty ?? null, estimatedCost: item.estimatedCost ?? null,
|
|
@@ -1634,7 +1940,7 @@ const APPROVAL_TEXT = Object.freeze({
|
|
|
1634
1940
|
*/
|
|
1635
1941
|
export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = null } = {}) {
|
|
1636
1942
|
const codes = []
|
|
1637
|
-
const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run']
|
|
1943
|
+
const runKinds = ['model_router_execute', 'model_router_rerun_step', 'model_router_team_execute', 'model_router_tool_run', 'model_router_consult']
|
|
1638
1944
|
if (!runKinds.includes(name)) return codes
|
|
1639
1945
|
if ((name === 'model_router_tool_run' || name === 'model_router_team_execute') && args.mode === 'workspace-write') codes.push('workspace-write')
|
|
1640
1946
|
if (name === 'model_router_rerun_step' && rerunRun && rerunSupport(rerunRun).writes) codes.push(rerunRun.kind === 'tool' ? 'workspace-write' : 'rerun-write')
|
|
@@ -1736,7 +2042,7 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
|
|
|
1736
2042
|
executor: 'official-team', executionMode: mode, executionEnvironment: planned.executionEnvironment, workspace, status: planned.status,
|
|
1737
2043
|
...(planned.reason ? { reason: planned.reason } : {}),
|
|
1738
2044
|
decision, selected: planned.plan?.selected ?? null, routingBypassed: false,
|
|
1739
|
-
estimatedCost: planned.plan?.estimatedCost ?? null, budget: planned.budget,
|
|
2045
|
+
accounting: planned.plan?.accounting ?? null, estimatedCost: planned.plan?.accounting ? planned.plan.accounting.apiEstimatedCostUsd : planned.plan?.estimatedCost ?? null, budget: planned.budget,
|
|
1740
2046
|
reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function', executionEnvironment: planned.executionEnvironment }),
|
|
1741
2047
|
readOnly: mode === 'read-only',
|
|
1742
2048
|
} }
|
|
@@ -1757,7 +2063,8 @@ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {})
|
|
|
1757
2063
|
decision: decisionSummary(planned.plan),
|
|
1758
2064
|
selected: planned.plan.selected ?? null,
|
|
1759
2065
|
routingBypassed: planned.plan.routingBypassed === true,
|
|
1760
|
-
|
|
2066
|
+
accounting: planned.plan.accounting ?? null,
|
|
2067
|
+
estimatedCost: planned.plan.accounting ? planned.plan.accounting.apiEstimatedCostUsd : planned.plan.estimatedCost ?? null,
|
|
1761
2068
|
budget: planned.budget,
|
|
1762
2069
|
reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function' }),
|
|
1763
2070
|
readOnly: true,
|
|
@@ -2012,6 +2319,7 @@ export function apply(ctx, config = {}) {
|
|
|
2012
2319
|
provider: { type: 'string', description: 'Configured provider id; pair with model.' },
|
|
2013
2320
|
model: { type: 'string', description: 'Configured model id; pair with provider.' },
|
|
2014
2321
|
outputLimit: { type: 'number', description: 'Maximum returned characters, from 500 to 50000.' },
|
|
2322
|
+
confirmOverBudget: { type: 'boolean', description: 'Only after explicit user approval when the API budget is exceeded or unverified.' },
|
|
2015
2323
|
},
|
|
2016
2324
|
output: JSON_OUTPUT,
|
|
2017
2325
|
async execute(args, exec) {
|
|
@@ -2022,7 +2330,7 @@ export function apply(ctx, config = {}) {
|
|
|
2022
2330
|
const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
|
|
2023
2331
|
const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
|
|
2024
2332
|
const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
|
|
2025
|
-
return jsonValue(await
|
|
2333
|
+
return jsonValue(await executeBudgetedConsult(ctx, route, args.task, config, { routes, limit, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true }))
|
|
2026
2334
|
},
|
|
2027
2335
|
}))
|
|
2028
2336
|
ctx.commands.register({
|
|
@@ -2239,14 +2547,14 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
|
|
|
2239
2547
|
const planned = await planToolRun(ctx, config, args, { signal: exec.signal })
|
|
2240
2548
|
if (planned.mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑 CLI 执行')
|
|
2241
2549
|
if (planned.budget.exceeded && args.confirmOverBudget !== true) {
|
|
2242
|
-
return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.estimate,
|
|
2550
|
+
return jsonValue({ status: 'paused-budget', budget: { ...planned.budget, paused: true }, estimateUsd: planned.apiEstimate ?? (planned.funding === 'subscription' ? 0 : planned.estimate), accounting: planned.accounting ?? null,
|
|
2243
2551
|
notice: `${planned.budget.message} 已暂停,未启动 ${planned.tool.label}。请向用户说明后,以 confirmOverBudget: true 重新调用。` })
|
|
2244
2552
|
}
|
|
2245
|
-
const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal })
|
|
2246
|
-
return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.estimate, budget: planned.budget,
|
|
2553
|
+
const { result, recorded } = await runPlannedTool(ctx, config, planned, { cwd, root, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true })
|
|
2554
|
+
return jsonValue({ ...result, runId: recorded?.id ?? null, estimateUsd: planned.apiEstimate ?? (planned.funding === 'subscription' ? 0 : planned.estimate), accounting: planned.accounting ?? null, budget: planned.budget,
|
|
2247
2555
|
...(!planned.modelId && planned.model ? { modelNotice: result.modelNotice
|
|
2248
2556
|
?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),
|
|
2249
|
-
...(planned.estimate === null ? { costNotice: '
|
|
2557
|
+
...(planned.funding === 'subscription' ? { costNotice: '订阅单独统计用量,不占用 API 金额预算;厂商未回报的用量保持未知。' } : planned.estimate === null ? { costNotice: '实际计费方式或 API 单价未知,请先核对后确认。' } : {}),
|
|
2250
2558
|
})
|
|
2251
2559
|
},
|
|
2252
2560
|
}))
|