@ljwei-stak/model-router-galgame 0.4.32 → 0.9.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.
Files changed (45) hide show
  1. package/.dsh-plugin/client.js +5438 -4
  2. package/.dsh-plugin/index.mjs +498 -727
  3. package/.dsh-plugin/official-tools-remote-service.mjs +152 -0
  4. package/.dsh-plugin/shared/harness-plan.mjs +134 -0
  5. package/.dsh-plugin/shared/model-profiles.mjs +101 -0
  6. package/.dsh-plugin/shared/official-team-runtime.mjs +349 -0
  7. package/.dsh-plugin/shared/official-tool-executor.mjs +780 -0
  8. package/.dsh-plugin/shared/official-tool-registry.mjs +123 -0
  9. package/.dsh-plugin/shared/official-tools-remote.mjs +136 -0
  10. package/.dsh-plugin/shared/official-tools-runtime.mjs +640 -0
  11. package/.dsh-plugin/shared/router.mjs +284 -73
  12. package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +298 -0
  13. package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
  14. package/.dsh-plugin/shared/zcode-bundle.mjs +193 -0
  15. package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
  16. package/INSTALLATION_GUIDE.zh.md +26 -290
  17. package/MIGRATION.md +24 -0
  18. package/README.md +23 -561
  19. package/README.zh.md +20 -526
  20. package/cordis.patch.yml +3 -37
  21. package/package.json +132 -112
  22. package/.dsh-plugin/shared/approval-gate.mjs +0 -109
  23. package/.dsh-plugin/shared/error-diagnostics.mjs +0 -17
  24. package/.dsh-plugin/shared/gal-game-service.mjs +0 -318
  25. package/.dsh-plugin/shared/gal-game.mjs +0 -271
  26. package/.dsh-plugin/shared/gal-story-afterword.mjs +0 -112
  27. package/.dsh-plugin/shared/gal-story-catalog.mjs +0 -27
  28. package/.dsh-plugin/shared/gal-story-ensemble.mjs +0 -440
  29. package/.dsh-plugin/shared/gal-story-personal.mjs +0 -437
  30. package/.dsh-plugin/shared/gal-story-v2-phase2.mjs +0 -397
  31. package/.dsh-plugin/shared/gal-story-v2-phase3.mjs +0 -341
  32. package/.dsh-plugin/shared/gal-story-v2.mjs +0 -497
  33. package/.dsh-plugin/shared/gal-story.mjs +0 -442
  34. package/.dsh-plugin/shared/liangshen-compat.mjs +0 -64
  35. package/.dsh-plugin/shared/modlens-routing.mjs +0 -21
  36. package/.dsh-plugin/shared/npm-update.mjs +0 -317
  37. package/.dsh-plugin/shared/persona.mjs +0 -93
  38. package/.dsh-plugin/shared/session-compat.mjs +0 -223
  39. package/.dsh-plugin/shared/watcher.mjs +0 -29
  40. package/.dsh-plugin/shared/web-routing.mjs +0 -90
  41. package/GAL_GAME_CONCEPT.zh.md +0 -266
  42. package/GAL_GAME_PREVIEW.md +0 -111
  43. package/GAL_STORY_V2_OUTLINE.zh.md +0 -323
  44. package/MODLENS_DEPLOYMENT.md +0 -72
  45. package//345/244/247/346/250/241/345/236/213/345/250/230/344/272/272/347/211/251/350/256/276/345/256/232.md +0 -276
@@ -1,795 +1,566 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import { DEFAULT_ROUTER_SETTINGS } from './shared/router.mjs'
4
+ import { createPlanFromRoutes } from './shared/harness-plan.mjs'
5
+ import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
6
+ import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
1
7
  import {
2
- buildPlan,
3
- collaborationInstruction,
4
- collaborationStage,
5
- collectOpenCodeEndpointRepairs,
6
- DEFAULT_ROUTER_SETTINGS,
7
- MODEL_ROUTER_SETTINGS_NAMESPACE,
8
- nextCollaborationStage,
9
- selectReasoningEffort,
10
- textFromMessages,
11
- } from './shared/router.mjs'
12
- import { formatErrorChain } from './shared/error-diagnostics.mjs'
13
- import { fetchLiveBenchSnapshot } from './shared/livebench.mjs'
14
- import { buildPersonaPrompt, isPersonaPrompt } from './shared/persona.mjs'
8
+ getOfficialTool,
9
+ installCommandLine,
10
+ toolForProvider,
11
+ } from './shared/official-tool-registry.mjs'
12
+ import { officialToolExecutionCapabilities, officialToolReadiness } from './shared/official-tool-executor.mjs'
13
+ import { runOfficialTask, runOfficialTeam, sessionWorkspace } from './shared/official-team-runtime.mjs'
15
14
  import {
16
- contentHasImage,
17
- modLensUpstream,
18
- routeThroughModLens,
19
- } from './shared/modlens-routing.mjs'
20
- import {
21
- approvalGateStatus,
22
- approvalSafetyContext,
23
- decorateApprovalReason,
24
- isApprovalGateReason,
25
- } from './shared/approval-gate.mjs'
26
- import {
27
- webCapabilityForPlan,
28
- webCapabilityStatus,
29
- webInstruction,
30
- } from './shared/web-routing.mjs'
31
- import { registerNpmUpdateRoute } from './shared/npm-update.mjs'
32
- import { registerGalGameRoutes } from './shared/gal-game-service.mjs'
33
- import { watcherStatus } from './shared/watcher.mjs'
34
- import { repairLiangshenPreset, reportLiangshenCompatibility } from './shared/liangshen-compat.mjs'
35
- import { repairRouterSessions, reportRouterSessionCompatibility } from './shared/session-compat.mjs'
36
-
37
- let settingsRuntimePromise
38
- let routerSettings = { ...DEFAULT_ROUTER_SETTINGS }
39
- let routerSettingsReady = false
40
- let routerSettingsPromise = Promise.resolve(false)
41
- async function loadSettingsRuntime() {
42
- if (settingsRuntimePromise !== undefined) return settingsRuntimePromise
43
- settingsRuntimePromise = Promise.all([
44
- import('@deepseek-ai/schemastery'),
45
- import('@deepseek-ai/dsh-settings'),
46
- ]).then(([schemaModule, settingsModule]) => ({
47
- z: schemaModule.default ?? schemaModule,
48
- settingsNamespace: settingsModule.settingsNamespace,
49
- }))
50
- return settingsRuntimePromise
51
- }
52
-
53
- function routerSettingsSchema(z) {
54
- const price = z.object({
55
- input: z.number().min(0).default(0),
56
- output: z.number().min(0).default(0),
57
- cacheRead: z.number().min(0).default(0),
58
- cacheWrite: z.number().min(0).default(0),
59
- currency: z.string().default('USD'),
60
- })
61
- return z.object({
62
- pricing: z.dict(price).default({}),
63
- liveBenchEndpoint: z.string().default(DEFAULT_ROUTER_SETTINGS.liveBenchEndpoint),
64
- liveBenchTtlMs: z.number().min(30000).default(DEFAULT_ROUTER_SETTINGS.liveBenchTtlMs),
65
- budgetUsd: z.number().min(0).default(DEFAULT_ROUTER_SETTINGS.budgetUsd),
66
- cacheReadRatio: z.number().min(0).max(1).default(DEFAULT_ROUTER_SETTINGS.cacheReadRatio),
67
- cacheWriteRatio: z.number().min(0).max(1).default(DEFAULT_ROUTER_SETTINGS.cacheWriteRatio),
68
- })
69
- }
70
-
71
- function invalidateRouterPlans() {
72
- for (const state of allStates) {
73
- state.plan = null
74
- state.liveBenchPromise = null
75
- state.liveBenchError = null
76
- }
77
- }
78
-
79
- async function registerRouterSettings(ctx) {
80
- const settings = settingsService(ctx)
81
- if (settings === undefined || typeof settings.register !== 'function') return false
82
- try {
83
- const runtime = await loadSettingsRuntime()
84
- const namespace = runtime.settingsNamespace(MODEL_ROUTER_SETTINGS_NAMESPACE)
85
- const scope = settings.register(namespace, routerSettingsSchema(runtime.z), {
86
- base: DEFAULT_ROUTER_SETTINGS,
87
- })
88
- routerSettings = scope.get()
89
- routerSettingsReady = true
90
- scope.watch(next => {
91
- routerSettings = next
92
- invalidateRouterPlans()
93
- })
94
- return true
95
- } catch (error) {
96
- ctx.logger?.warn?.(`model-router: settings registration unavailable: ${String(error)}`)
97
- return false
98
- }
99
- }
15
+ probeAllTools,
16
+ probeToolWith,
17
+ startInstall,
18
+ cancelInstall,
19
+ installStatus,
20
+ installedToolIds,
21
+ defaultRunner,
22
+ } from './shared/official-tools-runtime.mjs'
100
23
 
101
24
  export const name = 'model-router-galgame'
102
- export const inject = ['commands', 'llm', 'settings']
103
-
104
- const LLM_SETTINGS_NAMESPACE = 'llm-pi-ai'
105
- const OPEN_CODE_REPAIR_DELAYS_MS = Object.freeze([25, 100, 250, 500, 1000, 2000])
25
+ export const inject = ['commands', 'llm', 'tools', 'typert', 'sandboxPolicy', 'sandbox']
106
26
 
107
- const states = new WeakMap()
108
- const allStates = new Set()
27
+ export const Config = z.object({
28
+ budgetUsd: z.number().min(0).max(1_000_000).default(DEFAULT_ROUTER_SETTINGS.budgetUsd).volatile(),
29
+ maxConsultOutputChars: z.number().step(1).min(500).max(50_000).default(12_000).volatile(),
30
+ modelProfilesJson: z.string().max(32_000).default('[]').volatile(),
31
+ })
109
32
 
110
- function stateFor(agent) {
111
- let state = states.get(agent)
112
- if (state === undefined) {
113
- state = {
114
- mode: 'collective',
115
- plan: null,
116
- available: [],
117
- directoryPromise: null,
118
- turn: null,
119
- failedModels: new Set(),
120
- routeCooldowns: new Map(),
121
- lastTarget: null,
122
- lastStep: 0,
123
- collaboration: null,
124
- taskText: '',
125
- personaInjected: false,
126
- liveBench: null,
127
- liveBenchFetchedAt: 0,
128
- liveBenchPromise: null,
129
- liveBenchError: null,
130
- visionBridges: [],
131
- hasImageBlocks: false,
132
- }
133
- states.set(agent, state)
134
- allStates.add(state)
135
- }
136
- return state
33
+ const JSON_OUTPUT = {
34
+ schema: { type: 'json' },
35
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
137
36
  }
138
37
 
139
- async function discover(ctx, state) {
140
- if (state.directoryPromise !== null) return state.directoryPromise
141
- state.directoryPromise = (async () => {
142
- const routes = []
143
- const visionBridges = []
144
- let providers = []
145
- try { providers = ctx.llm.listProviders() } catch { providers = [] }
146
- for (const provider of providers) {
147
- // Synthetic ModLens routes are selectable in the native model picker,
148
- // but collective routing evaluates the underlying provider only. This
149
- // prevents a wrapper from competing with its own upstream route and
150
- // avoids a second image conversion in collective mode.
151
- const bridgeUpstream = modLensUpstream(provider?.id)
152
- if (bridgeUpstream !== null) {
153
- try {
154
- const models = await ctx.llm.listModels(provider.id)
155
- for (const model of models) visionBridges.push({ provider: provider.id, upstream: bridgeUpstream, model: model.id })
156
- } catch {
157
- // A failed synthetic catalog must not hide the usable upstream models.
158
- }
159
- continue
160
- }
161
- try {
162
- const models = await ctx.llm.listModels(provider.id)
163
- const resolvedRoutes = await Promise.all(models.map(async model => {
164
- let resolved
165
- try {
166
- resolved = typeof ctx.llm.resolveModelInfo === 'function'
167
- ? await ctx.llm.resolveModelInfo(provider.id, model.id)
168
- : undefined
169
- } catch (error) {
170
- ctx.logger?.debug?.(`model-router: reasoning metadata unavailable for ${provider.id}/${model.id}: ${String(error)}`)
171
- }
172
- const inputModalities = resolved?.inputModalities ?? model.inputModalities ?? model.input ?? []
173
- const reasoning = resolved?.reasoning
174
- return {
175
- provider: provider.id,
176
- model: model.id,
177
- inputModalities: Array.isArray(inputModalities) ? [...inputModalities] : [],
178
- ...(resolved === undefined ? {} : {
179
- reasoningKnown: true,
180
- reasoningEfforts: Array.isArray(reasoning?.efforts) ? reasoning.efforts.map(effort => effort.id) : [],
181
- ...(reasoning?.defaultEffort === undefined ? {} : { defaultReasoningEffort: reasoning.defaultEffort }),
182
- }),
183
- }
184
- }))
185
- routes.push(...resolvedRoutes)
186
- } catch (error) {
187
- ctx.logger?.debug?.(`model-router: model discovery failed for ${provider.id}: ${String(error)}`)
188
- }
189
- }
190
- state.available = routes
191
- state.visionBridges = visionBridges
192
- return routes
193
- })().catch(error => {
194
- state.directoryPromise = null
195
- ctx.logger?.warn?.(`model-router: model discovery unavailable: ${String(error)}`)
196
- return state.available
197
- })
198
- return state.directoryPromise
38
+ function jsonValue(value) {
39
+ return JSON.parse(JSON.stringify(value))
199
40
  }
200
41
 
201
- function inputText(messages) {
202
- return textFromMessages(messages).slice(-12000)
42
+ function valueOf(config, key, fallback) {
43
+ const value = config?.[key]
44
+ return value !== undefined && typeof value?.get === 'function' ? value.get() : value ?? fallback
203
45
  }
204
46
 
205
- async function liveBenchFor(ctx, state) {
206
- if (!routerSettingsReady) return state.liveBench
207
- const ttl = Math.max(30000, Number(routerSettings.liveBenchTtlMs) || DEFAULT_ROUTER_SETTINGS.liveBenchTtlMs)
208
- if (state.liveBench !== null && Date.now() - state.liveBenchFetchedAt < ttl) return state.liveBench
209
- if (state.liveBenchPromise !== null) return state.liveBenchPromise
210
- const configuredEndpoint = String(routerSettings.liveBenchEndpoint || DEFAULT_ROUTER_SETTINGS.liveBenchEndpoint)
211
- // Migrate the endpoint used by the first prototype; it returned 404 after
212
- // LiveBench moved to versioned CSV/JSON assets.
213
- const endpoint = configuredEndpoint === 'https://livebench.ai/api/leaderboard'
214
- ? DEFAULT_ROUTER_SETTINGS.liveBenchEndpoint
215
- : configuredEndpoint
216
- state.liveBenchPromise = fetchLiveBenchSnapshot({
217
- endpoint,
218
- }).then(snapshot => {
219
- state.liveBench = snapshot
220
- state.liveBenchFetchedAt = snapshot.fetchedAt
221
- state.liveBenchError = null
222
- return snapshot
223
- }).catch(error => {
224
- state.liveBenchError = String(error)
225
- ctx.logger?.warn?.(`model-router: LiveBench refresh failed; using ${state.liveBench === null ? 'experimental baseline' : 'last snapshot'}: ${String(error)}`)
226
- return state.liveBench
227
- }).finally(() => {
228
- state.liveBenchPromise = null
229
- })
230
- return state.liveBenchPromise
47
+ function finiteNumber(value, fallback) {
48
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
231
49
  }
232
50
 
233
- function newMessageId() {
234
- if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
235
- return `model-router-${Date.now()}-${Math.random().toString(16).slice(2)}`
51
+ function boundedInteger(value, fallback, minimum, maximum) {
52
+ return Math.min(maximum, Math.max(minimum, Math.floor(finiteNumber(value, fallback))))
236
53
  }
237
54
 
238
- /** Create a durable model-facing context row for a collaboration stage. */
239
- function stageMessage(plan, step) {
240
- const text = collaborationInstruction(plan, step)
241
- if (text === '') return null
242
- return {
243
- id: newMessageId(),
244
- role: 'user',
245
- content: [{ type: 'text', text }],
246
- source: { kind: 'plugin', plugin: name, form: 'relay' },
247
- }
55
+ function text(value) {
56
+ return typeof value === 'string' ? value.trim() : ''
248
57
  }
249
58
 
250
- function webMessage(plan) {
251
- const text = webInstruction(plan?.web)
252
- if (text === '') return null
253
- return {
254
- id: newMessageId(),
255
- role: 'user',
256
- content: [{ type: 'text', text }],
257
- source: { kind: 'plugin', plugin: name, form: 'instructions' },
258
- }
59
+ function errorText(error) {
60
+ if (error instanceof Error && error.message) return error.message
61
+ try { return String(error) } catch { return 'unknown error' }
259
62
  }
260
63
 
261
- /**
262
- * Persona is a final-answer context only. It is intentionally a separate
263
- * message so the collaboration stages and their audit records remain free of
264
- * stylistic instructions.
265
- */
266
- function personaMessage(state, agent, step) {
267
- const plan = state.plan
268
- const isCollective = state.mode === 'collective'
269
- const stage = isCollective ? collaborationStage(plan, step) : null
270
- const finalStage = !isCollective || stage === null || stage.purpose === 'synthesis'
271
- || plan?.complexity?.band !== 'complex'
272
- if (!finalStage) return null
273
- let route = state.lastTarget
274
- if (isCollective && plan !== null && plan !== undefined) {
275
- const task = plan.complexity?.band === 'complex' ? plan.subtasks?.[Math.max(1, Number(step) || 1) - 1] : plan.subtasks?.[0]
276
- if (task?.recommended) route = { provider: task.recommendedProvider, model: task.recommended }
277
- if (task?.purpose === 'synthesis' && plan.synthesizer?.model) route = plan.synthesizer
278
- if (route?.model === undefined || route?.model === '') route = plan.selected
279
- }
280
- if (!isCollective) {
281
- const header = typeof agent.session?.requestHeader === 'function' ? agent.session.requestHeader() : null
282
- route = route ?? header?.config ?? agent.options
283
- }
284
- const text = buildPersonaPrompt({
285
- provider: route?.provider ?? '',
286
- model: route?.model ?? '',
287
- mode: state.mode,
288
- stage: stage?.purpose === 'synthesis' ? 'synthesis' : 'answer',
289
- taskText: state.taskText,
290
- })
291
- return {
292
- id: newMessageId(),
293
- role: 'user',
294
- content: [{ type: 'text', text }],
295
- source: { kind: 'plugin', plugin: name, form: 'instructions' },
296
- }
64
+ function throwIfAborted(signal) {
65
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('operation aborted')
297
66
  }
298
67
 
299
- /** A short, auditable routing explanation shown before the first work stage. */
300
- function analysisMessage(plan) {
301
- if (plan === null || plan === undefined) return null
302
- const weights = plan.objectiveWeights ?? {}
303
- const selected = plan.selected === null || plan.selected === undefined
304
- ? '待发现模型'
305
- : `${plan.selected.provider}/${plan.selected.model}`
306
- const text = [
307
- '[Model Router 路由分析]',
308
- `任务类型:${plan.taskType};复杂度:${plan.complexity?.band ?? 'unknown'}(${Math.round((plan.complexity?.value ?? 0) * 100)}%)`,
309
- Array.isArray(plan.taskTypes) && plan.taskTypes.length > 1 ? `业务方向:${plan.taskTypes.join('、')}(分别建立执行工作包)` : '',
310
- `本轮权重:质量 ${Math.round((weights.quality ?? 0) * 100)}%,成本 ${Math.round((weights.cost ?? 0) * 100)}%,推理等级 ${Math.round((weights.reasoning ?? 0) * 100)}%,延迟 ${Math.round((weights.latency ?? 0) * 100)}%,专长 ${Math.round((weights.specialty ?? 0) * 100)}%,风险 ${Math.round((weights.risk ?? 0) * 100)}%`,
311
- `质量下限:${Math.round(Number(plan.optimization?.qualityFloor ?? 0) * 100)}%;首选路由:${selected}${plan.selected?.reasoningEffort ? `;推理等级:${plan.selected.reasoningEffort}` : ';推理等级:提供方默认'}`,
312
- `预计总费用:$${Number(plan.estimatedCost ?? 0).toFixed(6)};相对全高质量基线节省:$${Number((plan.optimization?.baselineAllStrongCost ?? 0) - (plan.estimatedCost ?? 0)).toFixed(6)}`,
313
- `缓存计费比例:读取 ${Math.round(Number(plan.optimization?.cacheReadRatio ?? 0) * 100)}%,写入 ${Math.round(Number(plan.optimization?.cacheWriteRatio ?? 0) * 100)}%(未填写时按普通输入计费)`,
314
- Number(plan.optimization?.budgetUsd ?? 0) > 0 ? `预算上限:$${Number(plan.optimization.budgetUsd).toFixed(6)};${plan.optimization.budgetExceeded ? '仍超预算,已在质量下限内尽量压缩' : '满足预算约束'}` : '',
315
- `LiveBench:${plan.optimization?.liveBench?.fetchedAt ? `快照于 ${new Date(Number(plan.optimization.liveBench.fetchedAt)).toISOString()}${plan.optimization.liveBench.stale ? '(本次刷新失败,沿用上次快照)' : ''}` : '未完成联网核验,使用实验基线'}`,
316
- plan.web?.needsWeb ? `联网策略:${plan.web.directBrowser ? 'Ego Browser 可见窗口优先' : 'ModSearch 搜索/抓取,失败时 Ego Browser 窗口兜底'};反爬处理:人工接管后继续` : '',
317
- String(plan.reason ?? ''),
318
- ].filter(Boolean).join('\n')
319
- return {
320
- id: newMessageId(),
321
- role: 'user',
322
- content: [{ type: 'text', text }],
323
- source: { kind: 'plugin', plugin: name, form: 'notice', summary: '路由分析与任务分配' },
324
- }
68
+ function routeKey(route) {
69
+ return `${route.provider}\u0000${route.model}`
325
70
  }
326
71
 
327
- function hasStageMarker(messages, step) {
328
- const marker = `[Model Router 协作阶段 ${step}/`
329
- return messages.some(message => message?.content?.some(block => typeof block?.text === 'string' && block.text.includes(marker)))
72
+ function configuredRoutesWithProfiles(routes, config) {
73
+ const profiles = parseModelProfilesJson(valueOf(config, 'modelProfilesJson', '[]'))
74
+ return applyModelProfiles(routes, profiles)
330
75
  }
331
76
 
332
- function shouldCollaborate(plan, available) {
333
- return plan?.complexity?.band === 'complex'
334
- && Array.isArray(plan.subtasks)
335
- && plan.subtasks.length >= 3
336
- && plan.selected !== null
337
- && plan.selected !== undefined
338
- && Array.isArray(available)
339
- && available.length > 0
77
+ function uniqueStrings(values) {
78
+ return [...new Set(values.map(text).filter(Boolean))]
340
79
  }
341
80
 
342
- function routeKey(provider, model) {
343
- return `${String(provider ?? '')}/${String(model ?? '')}`
344
- }
345
-
346
- function modelFallbackError(failure) {
347
- const text = `${String(failure?.code ?? '')} ${String(failure?.message ?? '')} ${formatErrorChain(failure)}`.toLowerCase()
348
- return /unsupported[_ -]reasoning[_ -]effort|no[_ -]adapter|invalid[_ -]model|invalid[_ -]credential|missing[_ -]credential|auth(?:entication|orization)?|api key|quota|region|not available|not supported|unsupported provider stream event|provider protocol|invalid (?:stream|event)|codex\.rate_limits|freeusagelimit|rate limit|too many requests|\b(?:401|403|404|429)\b/.test(text)
349
- }
350
-
351
- function failureCooldownMs(failure) {
352
- const text = `${String(failure?.code ?? '')} ${String(failure?.message ?? '')} ${formatErrorChain(failure)}`.toLowerCase()
353
- const requested = Number(failure?.retryAfterMs)
354
- if (Number.isFinite(requested) && requested > 0) return Math.min(Math.max(requested, 30000), 30 * 60 * 1000)
355
- if (/auth|api key|credential|\b401\b/.test(text)) return 30 * 60 * 1000
356
- if (/unsupported provider stream event|provider protocol|codex\.rate_limits/.test(text)) return 10 * 60 * 1000
357
- return 2 * 60 * 1000
81
+ /** Read only exact provider/model routes published by the official LLM service. */
82
+ export async function discoverConfiguredRoutes(ctx, signal) {
83
+ throwIfAborted(signal)
84
+ let providers
85
+ try { providers = ctx.llm.listProviders() } catch { return [] }
86
+ const routes = new Map()
87
+ for (const entry of Array.isArray(providers) ? providers : []) {
88
+ const provider = text(typeof entry === 'string' ? entry : entry?.id)
89
+ if (!provider) continue
90
+ throwIfAborted(signal)
91
+ let models
92
+ try { models = await ctx.llm.listModels(provider) } catch {
93
+ throwIfAborted(signal)
94
+ continue
95
+ }
96
+ for (const listed of Array.isArray(models) ? models : []) {
97
+ const model = text(listed?.id ?? listed?.model)
98
+ if (!model) continue
99
+ throwIfAborted(signal)
100
+ let resolved = listed
101
+ try { resolved = await ctx.llm.resolveModelInfo(provider, model, signal) } catch {
102
+ throwIfAborted(signal)
103
+ }
104
+ const reasoning = resolved?.reasoning
105
+ const route = {
106
+ provider,
107
+ model,
108
+ name: text(resolved?.name ?? listed?.name) || model,
109
+ inputModalities: uniqueStrings(Array.isArray(resolved?.inputModalities)
110
+ ? resolved.inputModalities : Array.isArray(listed?.inputModalities) ? listed.inputModalities : []),
111
+ reasoningKnown: reasoning !== undefined && reasoning !== null,
112
+ reasoningEfforts: uniqueStrings(Array.isArray(reasoning?.efforts) ? reasoning.efforts.map(item => item?.id ?? item) : []),
113
+ ...text(reasoning?.defaultEffort) ? { defaultReasoningEffort: text(reasoning.defaultEffort) } : {},
114
+ }
115
+ routes.set(routeKey(route), route)
116
+ }
117
+ }
118
+ return [...routes.values()].sort((left, right) => routeKey(left).localeCompare(routeKey(right)))
358
119
  }
359
120
 
360
- function routeTemporarilyUnavailable(state, key) {
361
- if (state.failedModels.has(key)) return true
362
- const deadline = Number(state.routeCooldowns.get(key) ?? 0)
363
- if (deadline <= Date.now()) {
364
- state.routeCooldowns.delete(key)
365
- return false
366
- }
367
- return true
121
+ /** Produce a route recommendation and work packages compatible with official Agent Teams. */
122
+ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
123
+ const taskText = text(task)
124
+ if (!taskText) throw new Error('task must contain text')
125
+ const mode = options.mode === 'team' ? 'team' : 'single'
126
+ const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
127
+ const budgetUsd = Math.max(0, finiteNumber(options.budgetUsd, finiteNumber(configuredBudget, 0)))
128
+ const [discoveredRoutes, installed] = await Promise.all([
129
+ discoverConfiguredRoutes(ctx, options.signal),
130
+ options.skipToolProbe === true
131
+ ? Promise.resolve(Array.isArray(options.installedToolIds) ? options.installedToolIds : [])
132
+ : installedToolIds(),
133
+ ])
134
+ const availableRoutes = configuredRoutesWithProfiles(discoveredRoutes, config)
135
+ const readiness = options.skipToolProbe === true ? []
136
+ : await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
137
+ const executable = new Set(Array.isArray(options.runnableToolIds)
138
+ ? options.runnableToolIds : readiness.filter(item => item.ready).map(item => item.id))
139
+ return createPlanFromRoutes(taskText, availableRoutes, {
140
+ mode, budgetUsd, installedToolIds: installed,
141
+ runnableToolIds: installed.filter(id => executable.has(id)),
142
+ })
368
143
  }
369
144
 
370
- function nextAvailableTarget(state, step = state.lastStep) {
371
- const candidates = Array.isArray(state.plan?.candidates) ? state.plan.candidates : []
372
- const assignment = state.plan?.subtasks?.[Math.max(0, Number(step || 1) - 1)]
373
- const preferredEffort = assignment?.preferredReasoningEffort ?? assignment?.recommendedReasoningEffort ?? 'medium'
374
- for (const candidate of candidates) {
375
- const key = routeKey(candidate.provider, candidate.model)
376
- if (routeTemporarilyUnavailable(state, key)) continue
377
- const route = state.available.find(entry => entry.provider === candidate.provider && entry.model === candidate.model)
378
- if (route !== undefined) {
379
- return {
380
- ...route,
381
- reasoningEffort: selectReasoningEffort(route.reasoningEfforts, preferredEffort),
145
+ /** One bounded, independent call through the same official LLM service. */
146
+ export async function consultConfiguredModel(ctx, route, task, outputLimit = 12_000, signal) {
147
+ const provider = text(route?.provider)
148
+ const model = text(route?.model)
149
+ const taskText = text(task)
150
+ if (!provider || !model) throw new Error('a configured provider and model are required')
151
+ if (!taskText) throw new Error('task must contain text')
152
+ throwIfAborted(signal)
153
+ const limit = boundedInteger(outputLimit, 12_000, 1, 50_000)
154
+ const controller = new AbortController()
155
+ const onAbort = () => controller.abort(signal.reason)
156
+ signal?.addEventListener('abort', onAbort, { once: true })
157
+ let answer = ''
158
+ let characterLimitReached = false
159
+ let finish = { kind: 'unknown' }
160
+ try {
161
+ const stream = ctx.llm.stream({
162
+ provider,
163
+ model,
164
+ messages: [{
165
+ role: 'user',
166
+ content: [{
167
+ type: 'text',
168
+ 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}`,
169
+ }],
170
+ }],
171
+ maxTokens: Math.min(4_096, Math.max(256, Math.ceil(limit / 2))),
172
+ signal: controller.signal,
173
+ })
174
+ for await (const chunk of stream) {
175
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
176
+ const remaining = limit - answer.length
177
+ if (remaining > 0) answer += chunk.text.slice(0, remaining)
178
+ if (chunk.text.length > remaining) {
179
+ characterLimitReached = true
180
+ controller.abort(new Error('consultation output limit reached'))
181
+ break
182
+ }
183
+ } else if (chunk?.type === 'finish') {
184
+ const reason = chunk.reason
185
+ finish = { kind: text(reason?.kind) || 'unknown' }
186
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
187
+ finish.error = text(reason?.failure?.message) || 'model request failed'
188
+ }
382
189
  }
383
190
  }
191
+ } finally {
192
+ signal?.removeEventListener('abort', onAbort)
193
+ }
194
+ throwIfAborted(signal)
195
+ const tokenLimitReached = finish.kind === 'max-tokens'
196
+ const truncated = characterLimitReached || tokenLimitReached
197
+ if (!characterLimitReached && (finish.kind === 'error' || finish.kind === 'aborted')) {
198
+ return { ok: false, provider, model, answer, truncated, finish, error: finish.error }
199
+ }
200
+ if (!answer.trim()) {
201
+ return { ok: false, provider, model, answer, truncated, finish, error: 'model returned no text' }
202
+ }
203
+ return {
204
+ ok: true, provider, model, answer, truncated, finish,
205
+ ...(tokenLimitReached
206
+ ? { truncationReason: 'model-token-limit', notice: '模型达到本次调用的输出 token 上限,回答可能不完整。' }
207
+ : characterLimitReached
208
+ ? { truncationReason: 'output-character-limit', notice: '回答达到字符上限,后续内容已截断。' }
209
+ : {}),
384
210
  }
385
- return null
386
211
  }
387
212
 
388
- function settingsService(ctx) {
389
- try {
390
- return ctx.get?.('settings') ?? ctx.settings
391
- } catch {
392
- return ctx.settings
393
- }
213
+ function explicitRoute(args, routes) {
214
+ const provider = text(args.provider)
215
+ const model = text(args.model)
216
+ if (Boolean(provider) !== Boolean(model)) throw new Error('provider and model must be supplied together')
217
+ if (!provider) return null
218
+ const route = routes.find(item => item.provider === provider && item.model === model)
219
+ if (!route) throw new Error(`route ${provider}/${model} is not configured in DeepSeek Harness`)
220
+ return route
394
221
  }
395
222
 
396
- /**
397
- * Remove an official OpenCode website URL only when it is a user override and
398
- * the built-in model catalog is still in use. This lets the catalog restore
399
- * its per-model /zen and /zen/v1 endpoints without touching custom gateways.
400
- */
401
- async function repairOpenCodeEndpoint(ctx) {
402
- const settings = settingsService(ctx)
403
- if (settings === undefined || typeof settings.describe !== 'function' || typeof settings.mutate !== 'function') {
404
- return 'unavailable'
405
- }
406
- let descriptor
407
- try {
408
- const descriptors = settings.describe()
409
- descriptor = (Array.isArray(descriptors) ? descriptors : [])
410
- .find(entry => String(entry?.ns) === LLM_SETTINGS_NAMESPACE)
411
- } catch (error) {
412
- ctx.logger?.debug?.(`model-router: settings inspection unavailable: ${String(error)}`)
413
- return 'unavailable'
414
- }
415
- // The settings service can be mounted after this plugin. Report this state
416
- // separately so the bounded startup retry can observe the namespace later.
417
- if (descriptor === undefined) return 'pending'
418
- const ops = collectOpenCodeEndpointRepairs(descriptor?.user)
419
- if (ops.length === 0) return 'clean'
223
+ function currentAgentRoute(agent) {
420
224
  try {
421
- await settings.mutate(LLM_SETTINGS_NAMESPACE, ops, descriptor.revision)
422
- ctx.logger?.info?.(`model-router: restored OpenCode catalog endpoints for ${ops.length} route(s)`)
423
- return 'repaired'
424
- } catch (error) {
425
- // A concurrent settings write can make the revision stale. The next
426
- // settings/updated event retries the same repair against the new revision.
427
- ctx.logger?.warn?.(`model-router: could not repair OpenCode endpoint: ${String(error)}`)
428
- return 'retry'
429
- }
225
+ const config = agent?.session?.requestHeader?.()?.config
226
+ if (text(config?.provider) && text(config?.model)) return { provider: config.provider, model: config.model }
227
+ } catch { /* background tools need not have a session-backed agent */ }
228
+ return null
430
229
  }
431
230
 
432
- /**
433
- * Serialize endpoint repairs and retry only while the settings namespace is
434
- * coming online or a concurrent write makes the revision stale. A bounded
435
- * timer avoids leaving a desktop process alive indefinitely during shutdown.
436
- */
437
- function createOpenCodeRepairScheduler(ctx) {
438
- const control = { inFlight: null, retryIndex: 0 }
439
-
440
- const wait = delay => new Promise(resolve => setTimeout(resolve, delay))
231
+ function chooseConsultRoute(plan, routes, agent) {
232
+ const current = currentAgentRoute(agent)
233
+ const differs = route => current === null || routeKey(route) !== routeKey(current)
234
+ const preferred = routes.find(route => route.provider === plan.selected?.provider && route.model === plan.selected?.model)
235
+ if (preferred && differs(preferred)) return preferred
236
+ return routes.find(differs) ?? preferred ?? routes[0]
237
+ }
441
238
 
442
- /**
443
- * Keep one repair promise for all callers. Request hooks await the same
444
- * bounded retry sequence, so a startup registration race cannot leak a
445
- * stale OpenCode website URL into the next model request.
446
- */
447
- const run = async () => {
448
- let result = 'retry'
449
- for (let attempt = 0; attempt <= OPEN_CODE_REPAIR_DELAYS_MS.length; attempt += 1) {
450
- result = await repairOpenCodeEndpoint(ctx)
451
- if (result === 'clean' || result === 'repaired') {
452
- control.retryIndex = 0
453
- return result
454
- }
455
- if (attempt === OPEN_CODE_REPAIR_DELAYS_MS.length) return result
456
- control.retryIndex = attempt + 1
457
- await wait(OPEN_CODE_REPAIR_DELAYS_MS[attempt])
458
- }
459
- return result
239
+ /** Manual package > manual tool > saved route mapping > CLI default. */
240
+ export function resolveTeamCliModelBindings(plan, routes, requested = {}) {
241
+ if (!requested || typeof requested !== 'object' || Array.isArray(requested)) {
242
+ throw new Error('cliModelsJson 必须是以工作包或官方工具 ID 为键的 JSON 对象')
460
243
  }
461
-
462
- const schedule = () => {
463
- if (control.inFlight !== null) return control.inFlight
464
- const promise = run().catch(error => {
465
- ctx.logger?.debug?.(`model-router: OpenCode repair scheduler failed: ${String(error)}`)
466
- return 'retry'
467
- })
468
- control.inFlight = promise
469
- void promise.finally(() => {
470
- if (control.inFlight === promise) control.inFlight = null
471
- })
472
- return promise
244
+ const byRoute = new Map(routes.map(route => [routeKey(route), route]))
245
+ const bindings = Object.create(null)
246
+ for (const item of plan.team.workPackages) {
247
+ const toolId = toolForProvider(item.recommendedProvider)?.id
248
+ const profile = byRoute.get(`${item.recommendedProvider}\u0000${item.recommendedModel}`)
249
+ if (profile?.cliModel && toolId !== 'zcode') bindings[item.id] = profile.cliModel
250
+ if (Object.hasOwn(requested, toolId)) bindings[item.id] = requested[toolId]
251
+ if (Object.hasOwn(requested, item.id)) bindings[item.id] = requested[item.id]
473
252
  }
474
-
475
- return schedule
253
+ // Keep the supplied keys so the runtime can reject unknown tool/package IDs.
254
+ return { ...bindings, ...Object.fromEntries(Object.entries(requested)
255
+ .filter(([key]) => !Object.hasOwn(bindings, key))) }
476
256
  }
477
257
 
478
- export function apply(ctx) {
479
- const repairLiangshen = () => reportLiangshenCompatibility(ctx, repairLiangshenPreset())
480
- repairLiangshen()
481
- reportRouterSessionCompatibility(ctx, repairRouterSessions())
482
-
483
- ctx.inject?.(['connection', 'llm', 'webServer'], galCtx => {
484
- registerGalGameRoutes(galCtx)
485
- })
486
-
487
- // Desktop-only package mutation is exposed through an authenticated,
488
- // fixed-purpose route when the optional native capabilities are present.
489
- ctx.inject?.(['connection', 'desktopProfiles', 'desktopPnpm', 'webServer'], desktopCtx => {
490
- registerNpmUpdateRoute(desktopCtx, import.meta.url)
491
- })
258
+ function commandText(plan) {
259
+ const selected = plan.selected ? `${plan.selected.provider}/${plan.selected.model}` : '没有可用路线'
260
+ const channel = plan.executionChannel === 'official-cli'
261
+ ? `官方 CLI(${plan.channelLabel ?? plan.channelTool})`
262
+ : '官方模型目录 API'
263
+ return [
264
+ `推荐路线:${selected}`,
265
+ `复杂度:${plan.complexity.band};任务类型:${plan.taskType}`,
266
+ `执行渠道:${channel};估算成本:${plan.estimatedCost === null ? '价格资料不足' : `$${plan.estimatedCost.toFixed(6)}(仅估算)`}`,
267
+ `工作包:${plan.subtasks.map(item => `${item.name} → ${item.recommendedProvider}/${item.recommended}`).join(';')}`,
268
+ plan.team.handoff,
269
+ ].join('\n')
270
+ }
492
271
 
493
- // Settings are optional in headless test/minimal hosts. In a full Harness
494
- // process this registers the editable pricing, LiveBench and budget section;
495
- // the dynamic import keeps the standalone plugin loadable during bootstrap.
496
- if (typeof ctx.inject === 'function') {
497
- routerSettingsPromise = new Promise(resolve => {
498
- let settled = false
499
- const finish = value => {
500
- if (settled) return
501
- settled = true
502
- resolve(value)
503
- }
504
- const timer = setTimeout(() => finish(false), 250)
505
- try {
506
- ctx.inject(['settings'], settingsCtx => {
507
- void registerRouterSettings(settingsCtx).then(value => {
508
- clearTimeout(timer)
509
- finish(value)
510
- })
511
- })
512
- } catch {
513
- clearTimeout(timer)
514
- finish(false)
272
+ /** Register model-facing tools and the human /router command. */
273
+ export function apply(ctx, config = {}) {
274
+ // The official Host injects typert; direct lightweight uses of apply may
275
+ // supply only the model/command services and do not expose the Desktop RPC.
276
+ if (ctx.typert) registerOfficialToolsRemote(ctx)
277
+ ctx.on('tools/pre-execute', async (exec, next) => {
278
+ const decision = await next()
279
+ if (decision.kind !== 'allow') return decision
280
+ if (exec.name === 'model_router_tool_install') {
281
+ const requested = getOfficialTool(text(exec.arguments?.tool))
282
+ const label = requested?.label ?? '官方 CLI'
283
+ const desktopInstaller = requested?.manager === 'signed-windows-installer'
284
+ return {
285
+ kind: 'ask',
286
+ reason: desktopInstaller
287
+ ? `Download and open the verified official ${label} desktop installer`
288
+ : `Install ${label} globally with the plugin's fixed official command`,
289
+ displayReason: {
290
+ en: desktopInstaller
291
+ ? `Download and open the verified ${label} installer? You can select the installation directory in its window.`
292
+ : `Install ${label} globally using the fixed official package?`,
293
+ zh: desktopInstaller
294
+ ? `下载并打开已验签的 ${label} 安装器?安装窗口中可选择非 C 盘目录。`
295
+ : `使用插件固定的官方软件包,在本机全局安装 ${label}?`,
296
+ },
515
297
  }
516
- })
517
- } else {
518
- routerSettingsPromise = registerRouterSettings(ctx)
519
- }
520
- const scheduleOpenCodeRepair = createOpenCodeRepairScheduler(ctx)
521
-
522
- // dsh-approval-gate owns the actual decision. The router adds auditable
523
- // stage/route context before that waterfall so multi-task escalations are
524
- // visible to the gate's Flash classifier and human reviewer. The request
525
- // object is borrowed by the Host approval service for this dispatch only.
526
- ctx.on('approval/request', (request, next) => {
527
- if (!isApprovalGateReason(request?.reason)) return next()
528
- const state = request?.agent === undefined ? null : stateFor(request.agent)
529
- const context = approvalSafetyContext(state, state?.lastStep)
530
- const decorated = decorateApprovalReason(request.reason, context)
531
- if (decorated !== request.reason) {
532
- try {
533
- request.reason = decorated
534
- } catch {
535
- // Some hosts freeze event payloads. In that case the gate still
536
- // receives the original reason and remains fully fail-safe.
298
+ }
299
+ if ((exec.name === 'model_router_tool_run' || exec.name === 'model_router_team_execute')
300
+ && exec.arguments?.mode === 'workspace-write') {
301
+ return {
302
+ kind: 'ask',
303
+ reason: 'Official CLI models will use their normal tools in an isolated Git worktree and integrate their patch into the current workspace',
304
+ displayReason: {
305
+ en: 'Allow the official CLI model to use shell, skills, configured MCP and other normal tools in an isolated Git worktree, then apply its patch to this workspace?',
306
+ zh: '允许官方 CLI 模型在独立 Git 工作区使用终端、技能、已配置 MCP 等工具,并将改动补丁应用回当前工作区?',
307
+ },
537
308
  }
538
309
  }
539
- return next()
540
- }, { prepend: true })
541
-
310
+ return decision
311
+ })
312
+ registerOfficialToolModels(ctx, config)
313
+ ctx.tools.register(defineTool({
314
+ name: 'model_router_routes',
315
+ 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.',
316
+ parameters: {},
317
+ output: JSON_OUTPUT,
318
+ async execute(_args, exec) {
319
+ const routes = await discoverConfiguredRoutes(ctx, exec.signal)
320
+ return jsonValue({ routes, count: routes.length, availabilityNotice: '目录记录不证明账号凭据和网络当前可用。' })
321
+ },
322
+ }))
323
+ ctx.tools.register(defineTool({
324
+ name: 'model_router_plan',
325
+ 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.',
326
+ parameters: {
327
+ task: { type: 'string', required: true, description: 'Task to analyze.' },
328
+ mode: { type: 'string', enum: ['single', 'team'], description: 'Use team to produce Agent Teams work packages.' },
329
+ budgetUsd: { type: 'number', description: 'Optional local estimated cost ceiling in USD.' },
330
+ },
331
+ output: JSON_OUTPUT,
332
+ async execute(args, exec) {
333
+ return jsonValue(await createRoutePlan(ctx, args.task, config, { mode: args.mode, budgetUsd: args.budgetUsd, signal: exec.signal }))
334
+ },
335
+ }))
336
+ ctx.tools.register(defineTool({
337
+ name: 'model_router_consult',
338
+ 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.',
339
+ parameters: {
340
+ task: { type: 'string', required: true, description: 'Task or question for the consulted model.' },
341
+ provider: { type: 'string', description: 'Configured provider id; pair with model.' },
342
+ model: { type: 'string', description: 'Configured model id; pair with provider.' },
343
+ outputLimit: { type: 'number', description: 'Maximum returned characters, from 500 to 50000.' },
344
+ },
345
+ output: JSON_OUTPUT,
346
+ async execute(args, exec) {
347
+ const routes = await discoverConfiguredRoutes(ctx, exec.signal)
348
+ if (routes.length === 0) throw new Error('no configured model routes are available')
349
+ const requested = explicitRoute(args, routes)
350
+ const plan = requested ? null : await createRoutePlan(ctx, args.task, config, { signal: exec.signal })
351
+ const route = requested ?? chooseConsultRoute(plan, routes, exec.agent)
352
+ const fallback = valueOf(config, 'maxConsultOutputChars', 12_000)
353
+ const limit = boundedInteger(args.outputLimit, finiteNumber(fallback, 12_000), 500, 50_000)
354
+ return jsonValue(await consultConfiguredModel(ctx, route, args.task, limit, exec.signal))
355
+ },
356
+ }))
542
357
  ctx.commands.register({
543
358
  name: 'router',
544
- description: 'switch Model Router mode or inspect the latest routing plan',
545
- // A router mode command is metadata-only. Accepting an attached image
546
- // lets the client execute it without the generic command gate rejecting
547
- // the whole composer submission; the GAL client sends this command
548
- // without image bytes so the attachment remains available for the next
549
- // user turn.
550
- input: { hint: 'mode collective|single | plan | safety | watcher', images: true },
551
- recordInput: true,
552
- handler: ({ agent, rawInput }) => {
553
- const state = stateFor(agent)
554
- const value = String(rawInput ?? '').trim().toLowerCase()
555
- if (value === 'mode single' || value === 'single') {
556
- state.mode = 'single'
557
- if (state.plan !== null) state.plan = { ...state.plan, mode: 'single' }
558
- return { kind: 'success', text: 'Model Router 已切换到单独会话:保留你在原生模型选择器中的选择。' }
559
- }
560
- if (value === 'mode collective' || value === 'collective') {
561
- state.mode = 'collective'
562
- if (state.plan !== null) state.plan = { ...state.plan, mode: 'collective' }
563
- return { kind: 'success', text: 'Model Router 已切换到集体合作:下一条问题将按复杂度、专长、成本和延迟自动分配。' }
564
- }
565
- if (value === 'plan' || value === '') {
566
- return { kind: 'success', text: state.plan === null ? '还没有可展示的路由方案。' : JSON.stringify(state.plan) }
359
+ description: 'Show an official-model route recommendation for a task.',
360
+ input: { hint: 'Describe the task to plan' },
361
+ async handler({ rawInput, signal }) {
362
+ if (!text(rawInput)) return { kind: 'error', text: '用法:/router <需要规划的任务>' }
363
+ try {
364
+ const plan = await createRoutePlan(ctx, rawInput, config, { signal })
365
+ return { kind: 'success', text: commandText(plan) }
366
+ } catch (error) {
367
+ return { kind: 'error', text: `无法生成路由计划:${errorText(error)}` }
567
368
  }
568
- if (value === 'safety' || value === 'approval') {
569
- return { kind: 'success', text: JSON.stringify({ ...approvalGateStatus(ctx), context: approvalSafetyContext(state, state.lastStep) }) }
369
+ },
370
+ })
371
+ ctx.commands.register({
372
+ name: 'tools',
373
+ description: 'Show official model CLI install status, or install one registry tool.',
374
+ input: { hint: '留空查看状态;或输入 install/cancel <工具id>' },
375
+ async handler({ rawInput, signal }) {
376
+ const input = text(rawInput)
377
+ const installMatch = input.match(/^install\s+([A-Za-z0-9_-]+)$/i)
378
+ const cancelMatch = input.match(/^cancel\s+([A-Za-z0-9_-]+)$/i)
379
+ if (cancelMatch) {
380
+ try { return { kind: 'success', text: `已请求取消 ${cancelInstall(cancelMatch[1]).tool} 的安装;请使用 /tools 查看最新状态。` } }
381
+ catch (error) { return { kind: 'error', text: `无法取消安装:${errorText(error)}` } }
570
382
  }
571
- if (value === 'watcher') {
572
- const watcher = watcherStatus(ctx, agent)
573
- return { kind: 'success', text: JSON.stringify({
574
- ...watcher,
575
- router: { mode: state.mode, lastRoute: watcher.insights?.turn?.route ?? null },
576
- }) }
383
+ if (!installMatch) {
384
+ if (input) return { kind: 'error', text: '用法:/tools 查看状态,或 /tools install/cancel <工具id>' }
385
+ try {
386
+ const probes = await probeAllTools({ fresh: true })
387
+ const lines = probes.map(probe => {
388
+ const tool = getOfficialTool(probe.id)
389
+ const command = installCommandLine(tool)
390
+ const status = probe.status === 'installed'
391
+ ? `已安装 ${probe.version ?? ''}`
392
+ : probe.status === 'unsupported'
393
+ ? '不支持一键安装'
394
+ : '未安装'
395
+ return `• ${tool.label}(${probe.id}):${status}${command && probe.status === 'not-installed' ? `\n 安装:/tools install ${probe.id}(即 ${command})` : ''}`
396
+ })
397
+ return { kind: 'success', text: `官方工具状态:\n${lines.join('\n')}` }
398
+ } catch (error) {
399
+ return { kind: 'error', text: `探测失败:${errorText(error)}` }
400
+ }
577
401
  }
578
- if (value === 'web' || value === 'network') {
579
- return { kind: 'success', text: JSON.stringify({ ...webCapabilityStatus(ctx), context: webCapabilityForPlan(state.taskText, state.plan) }) }
402
+ try {
403
+ const job = startInstall(installMatch[1])
404
+ const tool = getOfficialTool(installMatch[1])
405
+ const settled = await waitForInstall(job.tool, signal)
406
+ if (settled.status === 'succeeded') {
407
+ const probe = await probeToolWith(tool, defaultRunner)
408
+ return { kind: 'success', text: `${tool.label} 安装完成${probe.version ? `,探测版本 ${probe.version}` : ''}。` }
409
+ }
410
+ if (settled.status === 'installer-opened') {
411
+ return { kind: 'success', text: `${tool.label} 官方安装器已验证并打开。请在安装窗口选择非 C 盘目录并完成安装,然后运行 /tools 重新检测;当前尚未确认安装完成。` }
412
+ }
413
+ return { kind: 'error', text: `${tool.label} 安装失败:${settled.error ?? '未知原因'}\n${settled.outputTail.slice(-6).join('\n')}` }
414
+ } catch (error) {
415
+ return { kind: 'error', text: `无法开始安装:${errorText(error)}` }
580
416
  }
581
- return { kind: 'error', text: '用法:/router mode collective、/router mode single、/router plan、/router safety、/router web 或 /router watcher' }
582
417
  },
583
418
  })
419
+ }
584
420
 
585
- ctx.on('llm/adapters-updated', () => {
586
- for (const state of allStates) {
587
- state.directoryPromise = null
588
- state.routeCooldowns.clear()
421
+ /** Poll an install job until it settles or the signal aborts. */
422
+ async function waitForInstall(toolId, signal) {
423
+ for (;;) {
424
+ if (signal?.aborted) {
425
+ try { cancelInstall(toolId) } catch { /* install may already have finished */ }
426
+ throw new Error('已请求取消安装;请重新检测实际安装状态。')
589
427
  }
590
- void scheduleOpenCodeRepair()
591
- })
592
-
593
- // Existing settings may have been loaded before this plugin mounted. The
594
- // event listener handles later edits; the initial call covers that startup
595
- // ordering without requiring users to remove and re-add the route.
596
- void scheduleOpenCodeRepair()
597
- ctx.on('settings/updated', (namespace) => {
598
- if (String(namespace) === 'dsh-liangshen') queueMicrotask(repairLiangshen)
599
- if (String(namespace) === LLM_SETTINGS_NAMESPACE) void scheduleOpenCodeRepair()
600
- })
601
- ctx.on('settings/document-updated', (namespace) => {
602
- if (String(namespace) === LLM_SETTINGS_NAMESPACE) void scheduleOpenCodeRepair()
603
- })
428
+ const job = installStatus(toolId)
429
+ if (!job) throw new Error('安装任务丢失。')
430
+ if (job.status !== 'running') return job
431
+ await new Promise(resolve => setTimeout(resolve, 1_500))
432
+ }
433
+ }
604
434
 
605
- ctx.on('agent/pre-step', async ({ agent, messages, signal, turn, step }, next) => {
606
- if (signal?.aborted) return next()
607
- // Repair before the mode check: single-session requests use the same
608
- // OpenCode catalog and must not inherit a stale route-level website URL.
609
- await scheduleOpenCodeRepair()
610
- const state = stateFor(agent)
611
- if (state.turn !== null && state.turn !== turn) {
612
- state.failedModels.clear()
613
- state.lastTarget = null
614
- state.lastStep = 0
615
- state.plan = null
616
- state.collaboration = null
617
- state.taskText = ''
618
- state.personaInjected = false
619
- state.hasImageBlocks = false
620
- }
621
- state.turn = turn
622
- state.hasImageBlocks ||= messages.some(message => contentHasImage(message?.content))
623
- if (state.mode !== 'collective' || signal?.aborted) {
624
- if (state.taskText === '') state.taskText = inputText(messages)
625
- const proposed = await next()
626
- if (signal?.aborted || proposed === undefined || proposed === null || proposed.kind !== 'enter') return proposed
627
- const hasPersona = proposed.messages.some(message => message?.content?.some(block => isPersonaPrompt(block?.text)))
628
- if (hasPersona) state.personaInjected = true
629
- const personaContext = state.personaInjected ? null : personaMessage(state, agent, Number.isFinite(Number(step)) ? Number(step) : 1)
630
- if (personaContext === null) return proposed
631
- if (personaContext !== null) state.personaInjected = true
632
- return { ...proposed, messages: [...proposed.messages, personaContext] }
633
- }
634
- const available = await discover(ctx, state)
635
- if (signal?.aborted) return next()
636
- if (state.plan === null || state.plan.mode !== state.mode) {
637
- await routerSettingsPromise
638
- state.taskText = inputText(messages)
639
- const liveBench = await liveBenchFor(ctx, state)
640
- const readyRoutes = available.filter(route => !routeTemporarilyUnavailable(state, routeKey(route.provider, route.model)))
641
- const routable = readyRoutes.length > 0 ? readyRoutes : available
642
- const plan = buildPlan({
643
- text: state.taskText,
644
- available: routable,
645
- mode: state.mode,
646
- pricing: routerSettings.pricing,
647
- liveBench,
648
- liveBenchError: state.liveBenchError,
649
- budgetUsd: routerSettings.budgetUsd,
650
- cacheReadRatio: routerSettings.cacheReadRatio,
651
- cacheWriteRatio: routerSettings.cacheWriteRatio,
435
+ /** Register the model-facing official-tool probes and installer. */
436
+ function registerOfficialToolModels(ctx, config) {
437
+ ctx.tools.register(defineTool({
438
+ name: 'model_router_tools',
439
+ description: 'Probe the fixed registry of official model tools (Kimi Code, Claude Code, Codex, MiniMax Code, MiMo Code, Grok Build, ZCode) and report which are installed with their versions. Never reads credentials.',
440
+ parameters: {},
441
+ output: JSON_OUTPUT,
442
+ async execute(_args, exec) {
443
+ throwIfAborted(exec.signal)
444
+ const probes = await probeAllTools()
445
+ return jsonValue({
446
+ tools: probes,
447
+ executionCapabilities: officialToolExecutionCapabilities(),
448
+ executionReadiness: await Promise.all(probes.map(probe => probe.installed
449
+ ? officialToolReadiness(probe.id)
450
+ : Promise.resolve({ id: probe.id, ready: false, reason: 'CLI 尚未安装或版本检测失败。' }))),
451
+ installHint: '未安装的工具可由 model_router_tool_install 按注册表固定命令安装;版本与包名不接受自定义。',
652
452
  })
653
- state.plan = {
654
- ...plan,
655
- web: webCapabilityForPlan(state.taskText, plan),
656
- safety: {
657
- ...approvalGateStatus(ctx),
658
- ...approvalSafetyContext({ ...state, plan }, Number(step)),
659
- hardCategories: ['deletion', 'credential', 'remote', 'system', 'bulk'],
660
- failSafe: true,
661
- },
662
- }
663
- state.collaboration = shouldCollaborate(state.plan, routable)
664
- ? { lastStep: 0, queuedStep: null }
453
+ },
454
+ }))
455
+ ctx.tools.register(defineTool({
456
+ name: 'model_router_tool_install',
457
+ 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.',
458
+ parameters: {
459
+ tool: { type: 'string', required: true, description: 'Registry tool id, e.g. kimi-code.' },
460
+ },
461
+ output: JSON_OUTPUT,
462
+ async execute(args, exec) {
463
+ const job = startInstall(args.tool)
464
+ const settled = await waitForInstall(job.tool, exec.signal)
465
+ const tool = getOfficialTool(args.tool)
466
+ const probe = settled.status === 'succeeded'
467
+ ? await probeToolWith(tool, defaultRunner)
665
468
  : null
666
- }
667
- if (state.plan.selected !== null) {
668
- ctx.logger?.info?.(`model-router: ${state.plan.selected.provider}/${state.plan.selected.model} selected (${state.plan.reason})`)
669
- }
670
- const proposed = await next()
671
- if (proposed === undefined || proposed === null || proposed.kind !== 'enter') return proposed
672
- const currentStep = Number.isFinite(Number(step)) ? Number(step) : state.lastStep + 1
673
- const stageContext = stageMessage(state.plan, currentStep)
674
- const analysisContext = currentStep === 1 ? analysisMessage(state.plan) : null
675
- const webContext = currentStep === 1 ? webMessage(state.plan) : null
676
- const hasPersona = proposed.messages.some(message => message?.content?.some(block => isPersonaPrompt(block?.text)))
677
- if (hasPersona) state.personaInjected = true
678
- const personaContext = state.personaInjected ? null : personaMessage(state, agent, currentStep)
679
- const additions = []
680
- if (analysisContext !== null && !hasStageMarker(proposed.messages, currentStep)) additions.push(analysisContext)
681
- if (webContext !== null && !proposed.messages.some(message => message?.content?.some(block => block?.text === webContext.content[0].text))) additions.push(webContext)
682
- if (personaContext !== null) {
683
- additions.push(personaContext)
684
- state.personaInjected = true
685
- }
686
- if (stageContext !== null && !hasStageMarker(proposed.messages, currentStep)) additions.push(stageContext)
687
- state.lastStep = currentStep
688
- return additions.length === 0 ? proposed : { ...proposed, messages: [...proposed.messages, ...additions] }
689
- })
690
-
691
- ctx.on('agent/request', async ({ agent, step, signal }, next) => {
692
- // `agent/pre-step` normally runs first, but a request can be resumed while
693
- // settings are still being registered. This second guard closes that race.
694
- await scheduleOpenCodeRepair()
695
- const proposed = await next()
696
- if (signal?.aborted) return proposed
697
- const state = stateFor(agent)
698
- if (state.mode !== 'collective' || state.plan?.selected === null || state.plan?.selected === undefined) return proposed
699
- let target = state.plan.selected
700
- if (state.plan.complexity?.band === 'complex' && state.plan.subtasks?.length > 1) {
701
- const assignment = state.plan.subtasks[(Math.max(1, step) - 1) % state.plan.subtasks.length]
702
- const assignedRoute = state.available.find(route => route.provider === assignment?.recommendedProvider && route.model === assignment?.recommended)
703
- ?? state.available.find(route => route.model === assignment?.recommended)
704
- if (assignedRoute !== undefined) {
705
- target = { provider: assignedRoute.provider, model: assignedRoute.model, reasoningEffort: assignment?.recommendedReasoningEffort, estimatedCost: target.estimatedCost }
706
- }
707
- // The final subtask is the public answer synthesis. Prefer the user's
708
- // requested DeepSeek V4 Pro when it is actually available; otherwise the
709
- // deterministic plan's fallback remains in force and is shown in the UI.
710
- const synthesis = state.plan.synthesizer
711
- if (assignment?.purpose === 'synthesis' && synthesis?.provider && synthesis?.model) {
712
- const synthesisRoute = state.available.find(route => route.provider === synthesis.provider && route.model === synthesis.model)
713
- if (synthesisRoute !== undefined) {
714
- target = { provider: synthesisRoute.provider, model: synthesisRoute.model, reasoningEffort: synthesis.reasoningEffort, estimatedCost: target.estimatedCost }
715
- }
469
+ return jsonValue({
470
+ ...settled,
471
+ postInstallProbe: probe,
472
+ notice: settled.status === 'installer-opened'
473
+ ? '已打开 ZCode 官方安装窗口,请选择安装目录并完成安装,之后重新检测;此状态不代表安装完成。'
474
+ : '安装命令完全来自服务端注册表;实际版本以探测横幅为准。',
475
+ })
476
+ },
477
+ }))
478
+ ctx.tools.register(defineTool({
479
+ name: 'model_router_tool_run',
480
+ 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.',
481
+ parameters: {
482
+ tool: { type: 'string', required: true, description: 'Fixed registry tool id, e.g. claude-code or codex.' },
483
+ task: { type: 'string', required: true, description: 'Concrete task for the official CLI model.' },
484
+ provider: { type: 'string', description: 'Optional configured provider, paired with model.' },
485
+ model: { type: 'string', description: 'Optional model ID from the Harness directory, paired with provider. This ID is advisory for CLIs except Claude/Codex.' },
486
+ 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.' },
487
+ 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.' },
488
+ },
489
+ output: JSON_OUTPUT,
490
+ async execute(args, exec) {
491
+ const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
492
+ const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
493
+ if (mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑 CLI 执行')
494
+ let modelId = null
495
+ if (text(args.provider) || text(args.model)) {
496
+ if (!text(args.provider) || !text(args.model)) throw new Error('provider 和 model 必须同时提供')
497
+ const tool = toolForProvider(args.provider)
498
+ if (tool?.id !== args.tool) throw new Error('所选模型供应商与官方 CLI 工具不匹配')
499
+ const routes = configuredRoutesWithProfiles(await discoverConfiguredRoutes(ctx, exec.signal), config)
500
+ const route = routes.find(item => item.provider === args.provider && item.model === args.model)
501
+ if (!route) throw new Error('所选 provider/model 不在官方模型目录中')
502
+ modelId = route.cliModel && args.tool !== 'zcode'
503
+ ? route.cliModel
504
+ : args.tool === 'claude-code' || args.tool === 'codex' ? args.model : null
716
505
  }
717
- }
718
- if (routeTemporarilyUnavailable(state, routeKey(target.provider, target.model))) {
719
- const fallback = nextAvailableTarget(state, step)
720
- if (fallback !== null) {
721
- target = { provider: fallback.provider, model: fallback.model, reasoningEffort: fallback.reasoningEffort, estimatedCost: target.estimatedCost }
506
+ if (text(args.cliModel)) {
507
+ if (!text(args.provider) || !text(args.model)) throw new Error('cliModel 需要同时提供已配置的 provider 和 model 路线')
508
+ if (args.tool === 'zcode') throw new Error('ZCode 3.14.3 不支持在单次调用中指定 CLI 模型')
509
+ modelId = args.cliModel
722
510
  }
723
- }
724
- const plannedProvider = target.provider
725
- target = routeThroughModLens({
726
- target,
727
- available: state.available,
728
- visionBridges: state.visionBridges,
729
- hasImageBlocks: state.hasImageBlocks,
730
- })
731
- state.lastTarget = { provider: target.provider, model: target.model, reasoningEffort: target.reasoningEffort, plannedProvider }
732
- state.lastStep = step
733
- // Keep all non-routing request fields intact. If a route disappeared after
734
- // discovery, the LLM runtime will validate the proposal and the original
735
- // model remains available on the next step.
736
- const routed = { ...proposed, provider: target.provider, model: target.model }
737
- if (target.reasoningEffort === undefined) delete routed.reasoningEffort
738
- else routed.reasoningEffort = target.reasoningEffort
739
- return routed
740
- })
741
-
742
- // A completed work step would normally close the turn immediately. Queue the
743
- // next real collaboration stage at that boundary so the Harness agent loop
744
- // executes it as another logged model call. The final stage is the only one
745
- // allowed to answer the owner directly.
746
- ctx.on('agent/turn-stopping', ({ agent, signal }) => {
747
- const state = stateFor(agent)
748
- if (signal?.aborted || state.mode !== 'collective' || state.collaboration === null || state.plan === null) return
749
- const nextStage = nextCollaborationStage(state.plan, state.lastStep)
750
- if (nextStage === null) return
751
- const nextStep = state.lastStep + 1
752
- const message = stageMessage(state.plan, nextStep)
753
- if (message === null) return
754
- agent.inject(message)
755
- state.collaboration.queuedStep = nextStep
756
- ctx.logger?.info?.(`model-router: collaboration stage ${nextStep}/${state.plan.subtasks.length} queued`)
757
- })
758
-
759
- ctx.on('agent/request-error', async ({ agent, provider, failure, signal }, next) => {
760
- const state = stateFor(agent)
761
- if (signal?.aborted || state.mode !== 'collective' || !modelFallbackError(failure)) return next()
762
- const failed = state.lastTarget !== null && (provider === undefined || provider === null || state.lastTarget.provider === provider) ? state.lastTarget : null
763
- if (failed === null) return next()
764
- const failedKey = routeKey(failed.plannedProvider ?? failed.provider, failed.model)
765
- state.failedModels.add(failedKey)
766
- state.routeCooldowns.set(failedKey, Date.now() + failureCooldownMs(failure))
767
- const fallback = nextAvailableTarget(state, state.lastStep)
768
- if (fallback === null) return next()
769
- if (state.plan !== null) {
770
- const plan = state.plan
771
- const failedStage = plan.subtasks?.[Math.max(0, state.lastStep - 1)]
772
- const failedStageIndex = Math.max(0, state.lastStep - 1)
773
- const isSynthesisFailure = failedStage?.purpose === 'synthesis'
774
- const subtasks = Array.isArray(plan.subtasks)
775
- ? plan.subtasks.map((task, index) => index === failedStageIndex
776
- ? { ...task, recommendedProvider: fallback.provider, recommended: fallback.model, recommendedReasoningEffort: fallback.reasoningEffort }
777
- : task)
778
- : plan.subtasks
779
- state.plan = {
780
- ...plan,
781
- ...(plan.selected === null ? {} : { selected: { ...plan.selected, provider: fallback.provider, model: fallback.model, reasoningEffort: fallback.reasoningEffort } }),
782
- subtasks,
783
- ...(isSynthesisFailure ? {
784
- synthesizer: { provider: fallback.provider, model: fallback.model, reasoningEffort: fallback.reasoningEffort },
785
- } : {}),
511
+ const result = await runOfficialTask({ toolId: args.tool, task: args.task, modelId, workspace: cwd, allowedRoot: root, mode, signal: exec.signal, sandbox: ctx.sandbox })
512
+ return jsonValue({ ...result,
513
+ ...(!modelId && text(args.model) ? { modelNotice: result.modelNotice
514
+ ?? 'Harness 模型 ID 未经此厂商 CLI 验证;本次使用厂商 CLI 已配置的默认模型。' } : {}),
515
+ })
516
+ },
517
+ }))
518
+ ctx.tools.register(defineTool({
519
+ name: 'model_router_team_execute',
520
+ 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.',
521
+ parameters: {
522
+ task: { type: 'string', required: true, description: 'Full task to plan, distribute and execute.' },
523
+ mode: { type: 'string', enum: ['read-only', 'workspace-write'], description: 'Default read-only; workspace-write needs a clean Git repository and approval.' },
524
+ budgetUsd: { type: 'number', description: 'Estimated planning ceiling only, not a vendor billing limit.' },
525
+ 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.' },
526
+ },
527
+ output: JSON_OUTPUT,
528
+ async execute(args, exec) {
529
+ const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
530
+ const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
531
+ if (mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑团队执行')
532
+ const [discoveredRoutes, installed] = await Promise.all([discoverConfiguredRoutes(ctx, exec.signal), installedToolIds()])
533
+ const routes = configuredRoutesWithProfiles(discoveredRoutes, config)
534
+ const readiness = await Promise.all(installed.map(id => officialToolReadiness(id, cwd)))
535
+ const supported = new Set(readiness.filter(item => item.ready).map(item => item.id))
536
+ const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
537
+ const executableRoutes = routes.filter(route => {
538
+ const tool = toolForProvider(route.provider)
539
+ return tool && installed.includes(tool.id) && supported.has(tool.id)
540
+ && capabilities.get(tool.id)?.modes?.includes(mode)
541
+ })
542
+ if (executableRoutes.length === 0) return jsonValue({ status: 'blocked',
543
+ reason: `官方模型目录中没有同时满足已配置路线、已安装 CLI、托管执行适配器与 ${mode} 模式的供应商;Kimi、MiniMax、ZCode 仅支持经审批的 workspace-write。`,
544
+ installed, executionCapabilities: officialToolExecutionCapabilities(), executionReadiness: readiness })
545
+ const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
546
+ const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
547
+ const plan = createPlanFromRoutes(args.task, executableRoutes, {
548
+ mode: 'team', budgetUsd, installedToolIds: installed,
549
+ runnableToolIds: installed.filter(id => supported.has(id)),
550
+ })
551
+ const bindingsText = text(args.cliModelsJson)
552
+ if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
553
+ let cliModels = null
554
+ if (bindingsText) {
555
+ try { cliModels = JSON.parse(bindingsText) }
556
+ catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
786
557
  }
787
- }
788
- ctx.logger?.warn?.(`model-router: ${routeKey(failed.provider, failed.model)} unavailable; retrying with ${routeKey(fallback.provider, fallback.model)}`)
789
- return { kind: 'retry' }
790
- })
791
-
792
- ctx.on('agent/error', ({ agent, error }) => {
793
- ctx.logger?.warn?.(`model-router: agent ${String(agent.id)} failed: ${formatErrorChain(error)}`)
794
- })
558
+ cliModels = resolveTeamCliModelBindings(plan, executableRoutes, cliModels ?? {})
559
+ const execution = await runOfficialTeam({ plan, task: args.task, workspace: cwd,
560
+ allowedRoot: root, mode, installedIds: installed, cliModels, signal: exec.signal, sandbox: ctx.sandbox })
561
+ return jsonValue({ plan, execution,
562
+ modelNotice: '已配置 cliModel 的路线按工作包传给官方 CLI;Claude/Codex 在未配置映射时请求 Harness 模型 ID。其他厂商无映射时使用 CLI 默认模型。多数 CLI 尚不返回可核验的实际模型 ID,须以厂商运行记录核对。',
563
+ billingNotice: 'budgetUsd 仅影响估算与路由,无法限制官方 CLI 账号实际费用。' })
564
+ },
565
+ }))
795
566
  }