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