@weniatall/dsh-task-models 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -2
- package/cordis.patch.yml +36 -0
- package/lib/client.js +933 -0
- package/lib/core.js +387 -0
- package/lib/index.js +780 -0
- package/package.json +76 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-task-models: per-task-type subagent model routing for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* The plugin is standalone: it does not read or require any Agent Preset or
|
|
5
|
+
* role-routing plugin. It routes only delegated child sessions, decides once per
|
|
6
|
+
* child at its first request, and optionally asks TypeSafe Jev (or a
|
|
7
|
+
* configured lightweight classifier) when deterministic keywords do not
|
|
8
|
+
* settle the task type.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-task-models
|
|
11
|
+
*/
|
|
12
|
+
import z from '@deepseek-ai/schemastery'
|
|
13
|
+
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import zlib from 'node:zlib'
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_JEV,
|
|
20
|
+
DEFAULT_ROUTING,
|
|
21
|
+
childRouteIsExplicit,
|
|
22
|
+
isSubagent,
|
|
23
|
+
parseJevChoice,
|
|
24
|
+
parseProfile,
|
|
25
|
+
planModeActive,
|
|
26
|
+
profileJevRequest,
|
|
27
|
+
profileSystem,
|
|
28
|
+
resolveConfig,
|
|
29
|
+
selectProfile,
|
|
30
|
+
taskTextOf,
|
|
31
|
+
} from './core.js'
|
|
32
|
+
|
|
33
|
+
export const name = '@weniatall/dsh-task-models'
|
|
34
|
+
export const inject = ['settings', 'llm']
|
|
35
|
+
export const NS = 'task-models'
|
|
36
|
+
export const SETTINGS_RPC_CHANNEL = '/task-models-settings'
|
|
37
|
+
export const JEV_PING_INSTRUCTIONS = 'Is this a valid test ping?'
|
|
38
|
+
export function keyFilePath() {
|
|
39
|
+
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'task-models', 'typesafe.key')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
|
43
|
+
|
|
44
|
+
function liveField(schema) {
|
|
45
|
+
return typeof schema?.volatile === 'function' ? schema.volatile() : schema
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isVolatileRef(value) {
|
|
49
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
50
|
+
if (typeof value.get !== 'function') return false
|
|
51
|
+
return VOLATILE_WRITE in value || Object.keys(value).length === 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Plain composition layer for the legacy settings seam. */
|
|
55
|
+
function settingsBase(ctx, config) {
|
|
56
|
+
return unwrapConfig(ctx.fiber?.entry?.options?.config ?? config ?? {})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unwrapConfig(value, seen = new Set()) {
|
|
60
|
+
if (isVolatileRef(value)) return unwrapConfig(value.get(), seen)
|
|
61
|
+
if (value === null || typeof value !== 'object') return value
|
|
62
|
+
seen.add(value)
|
|
63
|
+
if (Array.isArray(value)) return value.map((item) => unwrapConfig(item, seen))
|
|
64
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapConfig(item, seen)]))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function settingsNamespace(ctx, fallback) {
|
|
68
|
+
if (typeof ctx.settings?.register === 'function') return fallback
|
|
69
|
+
const id = ctx.fiber?.entry?.options?.id
|
|
70
|
+
return typeof id === 'string' && id.length > 0 ? id : fallback
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function openSettingsScope(ctx, ns, schema, config) {
|
|
74
|
+
if (typeof ctx.settings?.register === 'function') {
|
|
75
|
+
return ctx.settings.register(ns, schema, { base: settingsBase(ctx, config) })
|
|
76
|
+
}
|
|
77
|
+
const read = () => {
|
|
78
|
+
const raw = typeof ctx.settings?.get === 'function' ? ctx.settings.get(ns) : config
|
|
79
|
+
return unwrapConfig(schema(unwrapConfig(raw ?? {})))
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
get: read,
|
|
83
|
+
watch(listener) {
|
|
84
|
+
if (typeof ctx.on !== 'function') return () => {}
|
|
85
|
+
return ctx.on('loader/volatile-update', () => listener(read()))
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const routeEntry = z.object({
|
|
91
|
+
provider: z.string().default(''),
|
|
92
|
+
model: z.string().default(''),
|
|
93
|
+
reasoningEffort: z.string().default(''),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const profileEntry = z.object({
|
|
97
|
+
id: z.string().required(),
|
|
98
|
+
name: z.string().default(''),
|
|
99
|
+
detail: z.string().default(''),
|
|
100
|
+
keywords: z.array(z.string()).default([]),
|
|
101
|
+
provider: z.string().required(),
|
|
102
|
+
model: z.string().required(),
|
|
103
|
+
reasoningEffort: z.string().default(''),
|
|
104
|
+
enabled: z.boolean().default(true),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/** Settings section and Cordis entry configuration. */
|
|
108
|
+
export const Config = z.object({
|
|
109
|
+
routing: liveField(z.object({
|
|
110
|
+
enabled: z.boolean().default(false),
|
|
111
|
+
fallback: z.string().default('taskRole'),
|
|
112
|
+
useJev: z.boolean().default(true),
|
|
113
|
+
respectExplicitChildModel: z.boolean().default(true),
|
|
114
|
+
classifier: routeEntry.default({}),
|
|
115
|
+
}).default({})),
|
|
116
|
+
profiles: liveField(z.array(profileEntry).default([])),
|
|
117
|
+
jev: liveField(z.object({
|
|
118
|
+
enabled: z.boolean().default(false),
|
|
119
|
+
baseURL: z.string().default(DEFAULT_JEV.baseURL),
|
|
120
|
+
model: z.string().default(DEFAULT_JEV.model),
|
|
121
|
+
minConfidence: z.number().min(0).max(1).default(DEFAULT_JEV.minConfidence),
|
|
122
|
+
timeoutMs: z.number().step(1).min(100).max(60_000).default(DEFAULT_JEV.timeoutMs),
|
|
123
|
+
maxStateChars: z.number().step(1).min(500).max(100_000).default(DEFAULT_JEV.maxStateChars),
|
|
124
|
+
}).default({})),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
export { isVolatileRef, liveField, settingsBase, unwrapConfig }
|
|
128
|
+
|
|
129
|
+
function badSettingsRequest(message) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error: {
|
|
133
|
+
code: 'bad-request',
|
|
134
|
+
message,
|
|
135
|
+
details: { issues: [{ code: 'custom', path: [], message }] },
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function settingsRpcView(settingsProvider, fallbackValue, targetNs = NS) {
|
|
141
|
+
const descriptor = typeof settingsProvider?.describe === 'function'
|
|
142
|
+
? settingsProvider.describe({ redactSecrets: true }).find((entry) => entry.ns === targetNs)
|
|
143
|
+
: undefined
|
|
144
|
+
if (descriptor !== undefined) {
|
|
145
|
+
return {
|
|
146
|
+
writable: settingsProvider.writable,
|
|
147
|
+
value: descriptor.value,
|
|
148
|
+
revision: descriptor.revision,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
writable: true,
|
|
153
|
+
value: unwrapConfig(fallbackValue ?? {}),
|
|
154
|
+
revision: 0,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readTextFile(path) {
|
|
159
|
+
try {
|
|
160
|
+
const text = await readFile(path, 'utf8')
|
|
161
|
+
return text.trim() === '' ? undefined : text.trim()
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The API Key lives in one fixed local file, never in the profile patch. */
|
|
168
|
+
async function resolveApiKey(overrideKey, keyPath = keyFilePath()) {
|
|
169
|
+
if (typeof overrideKey === 'string' && overrideKey.trim() !== '') return overrideKey.trim()
|
|
170
|
+
return readTextFile(keyPath)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function saveJevKey(apiKey, keyPath = keyFilePath()) {
|
|
174
|
+
const key = typeof apiKey === 'string' ? apiKey.trim() : ''
|
|
175
|
+
if (key === '') throw new Error('task-models: API key must be non-empty')
|
|
176
|
+
await mkdir(dirname(keyPath), { recursive: true, mode: 0o700 })
|
|
177
|
+
await writeFile(keyPath, key, { encoding: 'utf8', mode: 0o600 })
|
|
178
|
+
return keyPath
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function signalWithTimeout(signal, timeoutMs) {
|
|
182
|
+
const timeout = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined
|
|
183
|
+
if (signal === undefined || signal === null) return timeout
|
|
184
|
+
if (typeof AbortSignal.any === 'function' && timeout !== undefined) {
|
|
185
|
+
return AbortSignal.any([signal, timeout])
|
|
186
|
+
}
|
|
187
|
+
const controller = new AbortController()
|
|
188
|
+
const abort = (reason) => controller.abort(reason)
|
|
189
|
+
if (signal.aborted) abort(signal.reason)
|
|
190
|
+
else signal.addEventListener('abort', () => abort(signal.reason), { once: true })
|
|
191
|
+
if (timeout !== undefined) timeout.addEventListener('abort', () => abort(timeout.reason), { once: true })
|
|
192
|
+
return controller.signal
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function requestJev(jev, input, signal, overrideKey, keyPath = keyFilePath()) {
|
|
196
|
+
const state = input?.state
|
|
197
|
+
if (typeof state !== 'string' || state.trim() === '') {
|
|
198
|
+
throw new Error('task-models: Jev state must be non-empty')
|
|
199
|
+
}
|
|
200
|
+
const questions = input?.questions
|
|
201
|
+
if (questions === null || typeof questions !== 'object' || Array.isArray(questions)
|
|
202
|
+
|| Object.keys(questions).length === 0) {
|
|
203
|
+
throw new Error('task-models: Jev questions must be a non-empty object')
|
|
204
|
+
}
|
|
205
|
+
const apiKey = await resolveApiKey(overrideKey, keyPath)
|
|
206
|
+
if (apiKey === undefined) {
|
|
207
|
+
const error = new Error(`task-models: no TypeSafe API key. Save one in Settings -> 任务模型 -> Jev (${keyPath}).`)
|
|
208
|
+
error.code = 'unconfigured'
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
const url = `${String(jev.baseURL).replace(/\/+$/u, '')}/v1/systemone`
|
|
212
|
+
let response
|
|
213
|
+
try {
|
|
214
|
+
response = await fetch(url, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: {
|
|
217
|
+
'content-type': 'application/json',
|
|
218
|
+
authorization: `Bearer ${apiKey}`,
|
|
219
|
+
},
|
|
220
|
+
body: JSON.stringify({ state, questions, model: jev.model }),
|
|
221
|
+
signal: signalWithTimeout(signal, jev.timeoutMs),
|
|
222
|
+
})
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (signal?.aborted === true) {
|
|
225
|
+
const aborted = new Error('task-models: Jev request was aborted by the caller')
|
|
226
|
+
aborted.name = 'AbortError'
|
|
227
|
+
aborted.code = 'aborted'
|
|
228
|
+
throw aborted
|
|
229
|
+
}
|
|
230
|
+
const name = error instanceof Error ? error.name : ''
|
|
231
|
+
const code = name === 'TimeoutError' || name === 'AbortError' ? 'timeout' : 'network'
|
|
232
|
+
const wrapped = new Error(`task-models: Jev request failed (${code}): ${error instanceof Error ? error.message : String(error)}`)
|
|
233
|
+
wrapped.code = code
|
|
234
|
+
throw wrapped
|
|
235
|
+
}
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
const detail = await response.text().catch(() => '')
|
|
238
|
+
const error = new Error(`task-models: Jev returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`)
|
|
239
|
+
error.code = `http-${response.status}`
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
const data = await response.json().catch((error) => {
|
|
243
|
+
const wrapped = new Error(`task-models: Jev returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
244
|
+
wrapped.code = 'invalid-response'
|
|
245
|
+
throw wrapped
|
|
246
|
+
})
|
|
247
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)
|
|
248
|
+
|| data.answers === null || typeof data.answers !== 'object' || Array.isArray(data.answers)) {
|
|
249
|
+
const error = new Error('task-models: Jev response is missing the required answers object')
|
|
250
|
+
error.code = 'invalid-response'
|
|
251
|
+
throw error
|
|
252
|
+
}
|
|
253
|
+
return data
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function pingJev(jev, overrideKey, signal, keyPath = keyFilePath()) {
|
|
257
|
+
const startedAt = Date.now()
|
|
258
|
+
const data = await requestJev(jev, {
|
|
259
|
+
state: 'Ping check from DSH task-models',
|
|
260
|
+
questions: {
|
|
261
|
+
ping: { type: 'noul', instructions: JEV_PING_INSTRUCTIONS },
|
|
262
|
+
},
|
|
263
|
+
}, signal, overrideKey, keyPath)
|
|
264
|
+
return { latencyMs: Date.now() - startedAt, answers: data.answers }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Dedicated host RPC wire for a namespace hidden by DSH's settings allowlist. */
|
|
268
|
+
export async function handleSettingsRpc(settingsProvider, method, payload, currentSettings = {}, targetNs = NS, getChildModels, keyPath = keyFilePath()) {
|
|
269
|
+
const keys = payload === null || typeof payload !== 'object' || Array.isArray(payload)
|
|
270
|
+
? null
|
|
271
|
+
: Reflect.ownKeys(payload)
|
|
272
|
+
if (keys === null) return badSettingsRequest('task-models settings requests must carry an object')
|
|
273
|
+
|
|
274
|
+
let current
|
|
275
|
+
try {
|
|
276
|
+
current = resolveConfig(currentSettings)
|
|
277
|
+
} catch {
|
|
278
|
+
current = resolveConfig({})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
if (method === 'describe') {
|
|
283
|
+
if (keys.length !== 0) return badSettingsRequest('task-models describe requests must carry an empty object')
|
|
284
|
+
return { ok: true, value: settingsRpcView(settingsProvider, currentSettings, targetNs) }
|
|
285
|
+
}
|
|
286
|
+
if (method === 'replace') {
|
|
287
|
+
if (keys.length !== 2 || !Object.hasOwn(payload, 'section') || !Object.hasOwn(payload, 'expectedRevision')) {
|
|
288
|
+
return badSettingsRequest('task-models replace requests require section and expectedRevision')
|
|
289
|
+
}
|
|
290
|
+
if (payload.section === null || typeof payload.section !== 'object' || Array.isArray(payload.section)) {
|
|
291
|
+
return badSettingsRequest('task-models section must be an object')
|
|
292
|
+
}
|
|
293
|
+
if (!Number.isSafeInteger(payload.expectedRevision) || payload.expectedRevision < 0) {
|
|
294
|
+
return badSettingsRequest('task-models expectedRevision must be a non-negative integer')
|
|
295
|
+
}
|
|
296
|
+
await settingsProvider.replace(NS, payload.section, payload.expectedRevision)
|
|
297
|
+
return { ok: true, value: settingsRpcView(settingsProvider, payload.section, targetNs) }
|
|
298
|
+
}
|
|
299
|
+
if (method === 'jevStatus') {
|
|
300
|
+
if (keys.length !== 0) return badSettingsRequest('task-models jevStatus requests must carry an empty object')
|
|
301
|
+
const apiKey = await resolveApiKey(undefined, keyPath)
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
value: { configured: apiKey !== undefined, model: current.jev.model },
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (method === 'saveJevKey') {
|
|
308
|
+
if (keys.length !== 1 || !Object.hasOwn(payload, 'apiKey') || typeof payload.apiKey !== 'string') {
|
|
309
|
+
return badSettingsRequest('task-models saveJevKey requests require an apiKey string')
|
|
310
|
+
}
|
|
311
|
+
await saveJevKey(payload.apiKey, keyPath)
|
|
312
|
+
return { ok: true, value: { configured: true, model: current.jev.model } }
|
|
313
|
+
}
|
|
314
|
+
if (method === 'childModels') {
|
|
315
|
+
if (keys.length !== 1
|
|
316
|
+
|| !Object.hasOwn(payload, 'childIds')
|
|
317
|
+
|| !Array.isArray(payload.childIds)
|
|
318
|
+
|| payload.childIds.length > 500
|
|
319
|
+
|| payload.childIds.some((id) => typeof id !== 'string' || id === '')) {
|
|
320
|
+
return badSettingsRequest('task-models childModels requests require a childIds string array (max 500)')
|
|
321
|
+
}
|
|
322
|
+
const models = typeof getChildModels === 'function' ? await getChildModels(payload.childIds) : {}
|
|
323
|
+
return { ok: true, value: { models: models !== null && typeof models === 'object' ? models : {} } }
|
|
324
|
+
}
|
|
325
|
+
if (method === 'testJev') {
|
|
326
|
+
if (keys.length > 2
|
|
327
|
+
|| (Object.hasOwn(payload, 'apiKey') && typeof payload.apiKey !== 'string')
|
|
328
|
+
|| (Object.hasOwn(payload, 'jev') && (payload.jev === null || typeof payload.jev !== 'object' || Array.isArray(payload.jev)))) {
|
|
329
|
+
return badSettingsRequest('task-models testJev accepts an optional apiKey string and jev object')
|
|
330
|
+
}
|
|
331
|
+
const jevConfig = resolveConfig({
|
|
332
|
+
...currentSettings,
|
|
333
|
+
jev: { ...current.jev, ...(typeof payload.jev === 'object' && payload.jev !== null ? payload.jev : {}) },
|
|
334
|
+
}).jev
|
|
335
|
+
const result = await pingJev(
|
|
336
|
+
jevConfig,
|
|
337
|
+
typeof payload.apiKey === 'string' ? payload.apiKey : undefined,
|
|
338
|
+
typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(5_000) : undefined,
|
|
339
|
+
keyPath,
|
|
340
|
+
)
|
|
341
|
+
return { ok: true, value: { latencyMs: result.latencyMs, answers: result.answers } }
|
|
342
|
+
}
|
|
343
|
+
return badSettingsRequest(`Unknown task-models settings method: ${method}`)
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error: {
|
|
348
|
+
code: typeof error?.code === 'string' ? error.code : 'internal',
|
|
349
|
+
message: error instanceof Error ? error.message : String(error),
|
|
350
|
+
details: {},
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function promptTextOf(messages) {
|
|
357
|
+
if (!Array.isArray(messages)) return ''
|
|
358
|
+
const human = messages.filter((message) => message?.role === 'user'
|
|
359
|
+
&& (message.source?.kind === undefined || message.source.kind === 'user'))
|
|
360
|
+
const selected = human.length > 0 ? human : messages
|
|
361
|
+
return selected
|
|
362
|
+
.flatMap((message) => (Array.isArray(message?.content) ? message.content : []))
|
|
363
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
364
|
+
.map((block) => block.text)
|
|
365
|
+
.join('\n')
|
|
366
|
+
.trim()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function toRoute(profile) {
|
|
370
|
+
if (profile?.provider === undefined || profile?.model === undefined) return undefined
|
|
371
|
+
return {
|
|
372
|
+
provider: profile.provider,
|
|
373
|
+
model: profile.model,
|
|
374
|
+
...(profile.reasoningEffort === undefined || profile.reasoningEffort === ''
|
|
375
|
+
? {}
|
|
376
|
+
: { reasoningEffort: profile.reasoningEffort }),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function applyRoute(config, route) {
|
|
381
|
+
const { reasoningEffort: _previousEffort, ...rest } = config
|
|
382
|
+
return {
|
|
383
|
+
...rest,
|
|
384
|
+
provider: route.provider,
|
|
385
|
+
model: route.model,
|
|
386
|
+
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Register settings, the Jev client, and the subagent request router. */
|
|
391
|
+
export function apply(ctx, config = {}) {
|
|
392
|
+
const scope = openSettingsScope(ctx, NS, Config, config)
|
|
393
|
+
let settings = unwrapConfig(scope.get())
|
|
394
|
+
let routingConfig
|
|
395
|
+
try {
|
|
396
|
+
routingConfig = resolveConfig(settings)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
ctx.logger.warn?.('task-models: invalid settings; using defaults')
|
|
399
|
+
ctx.logger.warn?.(error)
|
|
400
|
+
routingConfig = resolveConfig({})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const firstStepText = new WeakMap()
|
|
404
|
+
const subagentLabels = new WeakMap()
|
|
405
|
+
const taskDecisions = new WeakMap()
|
|
406
|
+
const childModels = new Map()
|
|
407
|
+
const historicalModels = new Map()
|
|
408
|
+
let jevFailureLogged = false
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Remember the effective route of every delegated child request. The
|
|
412
|
+
* workflow UI asks for these by child session id, so a member row can show
|
|
413
|
+
* the model that actually served the child even when another role router,
|
|
414
|
+
* an explicit subagent model, or this plugin selected it.
|
|
415
|
+
*/
|
|
416
|
+
function rememberChildModel(agent, config) {
|
|
417
|
+
if (!isSubagent(agent)) return
|
|
418
|
+
const provider = typeof config?.provider === 'string' ? config.provider : ''
|
|
419
|
+
const model = typeof config?.model === 'string' ? config.model : ''
|
|
420
|
+
if (provider === '' || model === '') return
|
|
421
|
+
const id = agent?.session?.id ?? agent?.id
|
|
422
|
+
if (typeof id !== 'string' || id === '') return
|
|
423
|
+
const previous = childModels.get(id)
|
|
424
|
+
const next = Object.freeze({
|
|
425
|
+
provider,
|
|
426
|
+
model,
|
|
427
|
+
...(typeof config?.reasoningEffort === 'string' && config.reasoningEffort !== ''
|
|
428
|
+
? { reasoningEffort: config.reasoningEffort }
|
|
429
|
+
: {}),
|
|
430
|
+
})
|
|
431
|
+
if (previous !== undefined && previous.provider === next.provider
|
|
432
|
+
&& previous.model === next.model && previous.reasoningEffort === next.reasoningEffort) {
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
childModels.delete(id)
|
|
436
|
+
childModels.set(id, next)
|
|
437
|
+
if (childModels.size > 2_000) {
|
|
438
|
+
const oldest = childModels.keys().next().value
|
|
439
|
+
if (oldest !== undefined) childModels.delete(oldest)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function modelsFor(childIds) {
|
|
444
|
+
const models = {}
|
|
445
|
+
for (const id of childIds) {
|
|
446
|
+
const entry = childModels.get(id)
|
|
447
|
+
if (entry !== undefined) models[id] = entry
|
|
448
|
+
}
|
|
449
|
+
return models
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Best-effort backfill for workflow members whose child session predates
|
|
454
|
+
* this plugin instance (for example a run created before a restart). The
|
|
455
|
+
* durable child log records its last request/header, which contains the
|
|
456
|
+
* provider/model that actually served it. This is additive and never
|
|
457
|
+
* affects routing; a missing or unreadable log simply leaves the badge
|
|
458
|
+
* blank.
|
|
459
|
+
*/
|
|
460
|
+
async function historicalModelOf(childId) {
|
|
461
|
+
if (typeof childId !== 'string' || childId === '.' || childId === '..' || childId.includes('..')
|
|
462
|
+
|| !/^[A-Za-z0-9._:-]+$/u.test(childId)) return undefined
|
|
463
|
+
if (historicalModels.has(childId)) return historicalModels.get(childId) ?? undefined
|
|
464
|
+
let result
|
|
465
|
+
try {
|
|
466
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
467
|
+
const root = join(home, 'sessions')
|
|
468
|
+
const scopes = await readdir(root, { withFileTypes: true })
|
|
469
|
+
for (const scope of scopes) {
|
|
470
|
+
if (!scope.isDirectory()) continue
|
|
471
|
+
const base = join(root, scope.name, childId)
|
|
472
|
+
for (const name of ['session.v4.jsonl.zstd', 'session.v4.jsonl']) {
|
|
473
|
+
let raw
|
|
474
|
+
try {
|
|
475
|
+
raw = await readFile(join(base, name))
|
|
476
|
+
} catch {
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
let text
|
|
480
|
+
if (name.endsWith('.zstd')) {
|
|
481
|
+
if (typeof zlib.zstdDecompressSync !== 'function') continue
|
|
482
|
+
try {
|
|
483
|
+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
484
|
+
const offsets = []
|
|
485
|
+
let offset = raw.indexOf(magic)
|
|
486
|
+
while (offset !== -1) {
|
|
487
|
+
offsets.push(offset)
|
|
488
|
+
offset = raw.indexOf(magic, offset + 4)
|
|
489
|
+
}
|
|
490
|
+
const chunks = []
|
|
491
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
492
|
+
const end = index + 1 < offsets.length ? offsets[index + 1] : raw.length
|
|
493
|
+
try {
|
|
494
|
+
chunks.push(zlib.zstdDecompressSync(raw.subarray(offsets[index], end)).toString('utf8'))
|
|
495
|
+
} catch {
|
|
496
|
+
// A malformed frame never invalidates the other frames.
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
text = chunks.join('')
|
|
500
|
+
} catch {
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
text = raw.toString('utf8')
|
|
505
|
+
}
|
|
506
|
+
let found
|
|
507
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
508
|
+
if (!line.includes('"request/header"')) continue
|
|
509
|
+
try {
|
|
510
|
+
const event = JSON.parse(line)
|
|
511
|
+
const config = event?.data?.header?.config ?? event?.data?.config
|
|
512
|
+
if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
|
|
513
|
+
found = {
|
|
514
|
+
provider: config.provider,
|
|
515
|
+
model: config.model,
|
|
516
|
+
...(typeof config.reasoningEffort === 'string' && config.reasoningEffort !== ''
|
|
517
|
+
? { reasoningEffort: config.reasoningEffort }
|
|
518
|
+
: {}),
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
// Ignore a torn or non-JSON line.
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (found !== undefined) {
|
|
526
|
+
result = found
|
|
527
|
+
break
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (result !== undefined) break
|
|
531
|
+
}
|
|
532
|
+
} catch {
|
|
533
|
+
// Missing session storage is normal on a fresh machine.
|
|
534
|
+
}
|
|
535
|
+
historicalModels.set(childId, result ?? null)
|
|
536
|
+
if (historicalModels.size > 1_000) {
|
|
537
|
+
const oldest = historicalModels.keys().next().value
|
|
538
|
+
if (oldest !== undefined) historicalModels.delete(oldest)
|
|
539
|
+
}
|
|
540
|
+
return result
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function modelsForWithHistory(childIds) {
|
|
544
|
+
const models = modelsFor(childIds)
|
|
545
|
+
const missing = childIds.filter((id) => models[id] === undefined)
|
|
546
|
+
await Promise.all(missing.map(async (id) => {
|
|
547
|
+
const entry = await historicalModelOf(id)
|
|
548
|
+
if (entry !== undefined) models[id] = entry
|
|
549
|
+
}))
|
|
550
|
+
return models
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function warnJevOnce(message, error) {
|
|
554
|
+
if (jevFailureLogged) return
|
|
555
|
+
jevFailureLogged = true
|
|
556
|
+
ctx.logger.warn?.(message)
|
|
557
|
+
if (error !== undefined) ctx.logger.warn?.(error)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function labelOf(agent) {
|
|
561
|
+
const session = agent?.session
|
|
562
|
+
if (session === undefined) return undefined
|
|
563
|
+
const cached = subagentLabels.get(session)
|
|
564
|
+
if (cached !== undefined) return cached
|
|
565
|
+
const events = Array.isArray(session.events) ? session.events : []
|
|
566
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
567
|
+
const event = events[index]
|
|
568
|
+
if (event?.type === 'subagent/descriptor' && typeof event.data?.label === 'string') {
|
|
569
|
+
subagentLabels.set(session, event.data.label)
|
|
570
|
+
return event.data.label
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return undefined
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function parentOf(agent) {
|
|
577
|
+
const id = agent?.session?.header?.parentSession
|
|
578
|
+
if (typeof id !== 'string' || id === '') return undefined
|
|
579
|
+
try {
|
|
580
|
+
return ctx.get?.('agents')?.get?.(id)
|
|
581
|
+
} catch {
|
|
582
|
+
return undefined
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function requestHeaderOf(agent) {
|
|
587
|
+
try {
|
|
588
|
+
return agent?.session?.requestHeader?.()
|
|
589
|
+
} catch {
|
|
590
|
+
return undefined
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function childHasExplicitRoute(agent) {
|
|
595
|
+
const parent = parentOf(agent)
|
|
596
|
+
if (parent === undefined) return false
|
|
597
|
+
let parentConfig = {}
|
|
598
|
+
try {
|
|
599
|
+
parentConfig = parent.session?.requestHeader?.()?.config ?? parent.options ?? {}
|
|
600
|
+
} catch {
|
|
601
|
+
parentConfig = parent.options ?? {}
|
|
602
|
+
}
|
|
603
|
+
return childRouteIsExplicit(agent?.options, parentConfig)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function askJev(agent, cfg, label, text, signal) {
|
|
607
|
+
const parent = parentOf(agent)
|
|
608
|
+
const request = profileJevRequest({
|
|
609
|
+
parentTask: parent === undefined ? '' : taskTextOf(parent).slice(-2_000),
|
|
610
|
+
label,
|
|
611
|
+
text,
|
|
612
|
+
profiles: cfg.profiles,
|
|
613
|
+
maxStateChars: cfg.jev.maxStateChars,
|
|
614
|
+
})
|
|
615
|
+
const data = await requestJev(cfg.jev, request, signal)
|
|
616
|
+
return parseJevChoice(data, cfg.profiles, cfg.jev.minConfidence)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function classifyWithLlm(cfg, label, text, signal) {
|
|
620
|
+
const route = cfg.routing.classifier
|
|
621
|
+
if (!route?.provider || !route?.model) return undefined
|
|
622
|
+
const task = [
|
|
623
|
+
typeof label === 'string' && label.trim() !== '' ? `Subtask label:\n${label}` : '',
|
|
624
|
+
typeof text === 'string' && text.trim() !== '' ? `<subtask>\n${text}\n</subtask>` : '',
|
|
625
|
+
].filter(Boolean).join('\n\n').slice(-6_000)
|
|
626
|
+
if (task === '') return undefined
|
|
627
|
+
const assembler = new BlockAssembler()
|
|
628
|
+
for await (const chunk of ctx.llm.stream({
|
|
629
|
+
provider: route.provider,
|
|
630
|
+
model: route.model,
|
|
631
|
+
...(route.reasoningEffort === '' ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
632
|
+
messages: [createUserMessage({
|
|
633
|
+
content: [{ type: 'text', text: task }],
|
|
634
|
+
source: { kind: `plugin:${NS}` },
|
|
635
|
+
})],
|
|
636
|
+
system: profileSystem(cfg.profiles),
|
|
637
|
+
temperature: 0,
|
|
638
|
+
maxTokens: 128,
|
|
639
|
+
...(signal === undefined ? {} : { signal }),
|
|
640
|
+
})) assembler.push(chunk)
|
|
641
|
+
const output = assembler.blocks()
|
|
642
|
+
.filter((block) => block?.type === 'text')
|
|
643
|
+
.map((block) => block.text)
|
|
644
|
+
.join('\n')
|
|
645
|
+
return parseProfile(output, cfg.profiles)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function decideProfile(agent, cfg, label, text, signal) {
|
|
649
|
+
const deterministic = selectProfile(cfg.profiles, label, text)
|
|
650
|
+
if (deterministic !== undefined) return { profile: deterministic, source: 'rules' }
|
|
651
|
+
|
|
652
|
+
if (cfg.routing.useJev && cfg.jev.enabled) {
|
|
653
|
+
try {
|
|
654
|
+
const id = await askJev(agent, cfg, label, text, signal)
|
|
655
|
+
const profile = id === undefined ? undefined : cfg.profiles.get(id)
|
|
656
|
+
if (profile?.enabled) return { profile, source: 'jev' }
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (!(error?.name === 'AbortError' && signal?.aborted === true)) {
|
|
659
|
+
warnJevOnce('task-models: Jev task classification failed; falling back', error)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (cfg.routing.fallback === 'llm') {
|
|
665
|
+
const id = await classifyWithLlm(cfg, label, text, signal)
|
|
666
|
+
const profile = id === undefined ? undefined : cfg.profiles.get(id)
|
|
667
|
+
if (profile?.enabled) return { profile, source: 'llm' }
|
|
668
|
+
}
|
|
669
|
+
return undefined
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function decisionFor(agent, cfg, label, text, signal) {
|
|
673
|
+
const signature = `${label ?? ''}\u0000${text ?? ''}`
|
|
674
|
+
const cached = taskDecisions.get(agent)
|
|
675
|
+
if (cached !== undefined && cached.signature === signature) return cached.promise
|
|
676
|
+
const promise = decideProfile(agent, cfg, label, text, signal).catch((error) => {
|
|
677
|
+
ctx.logger.warn?.('task-models: task profile classification failed; keeping the current route')
|
|
678
|
+
ctx.logger.warn?.(error)
|
|
679
|
+
return undefined
|
|
680
|
+
})
|
|
681
|
+
taskDecisions.set(agent, { signature, promise })
|
|
682
|
+
return promise
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function routeUsable(route, signal) {
|
|
686
|
+
if (typeof ctx.llm?.resolveCallConfig !== 'function') return true
|
|
687
|
+
try {
|
|
688
|
+
await ctx.llm.resolveCallConfig(route, signal)
|
|
689
|
+
return true
|
|
690
|
+
} catch (error) {
|
|
691
|
+
ctx.logger.warn?.('task-models: selected profile route is not usable; keeping the current route')
|
|
692
|
+
ctx.logger.warn?.(error)
|
|
693
|
+
return false
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
scope.watch((next) => {
|
|
698
|
+
const value = unwrapConfig(next)
|
|
699
|
+
try {
|
|
700
|
+
const resolved = resolveConfig(value)
|
|
701
|
+
settings = value
|
|
702
|
+
routingConfig = resolved
|
|
703
|
+
} catch (error) {
|
|
704
|
+
ctx.logger.warn?.('task-models: keeping the last good settings after an invalid update')
|
|
705
|
+
ctx.logger.warn?.(error)
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
710
|
+
const targetNs = settingsNamespace(ctx, NS)
|
|
711
|
+
connectionCtx.connection.rpc.handle(
|
|
712
|
+
SETTINGS_RPC_CHANNEL,
|
|
713
|
+
(method, payload) => handleSettingsRpc(
|
|
714
|
+
ctx.settings,
|
|
715
|
+
method,
|
|
716
|
+
payload,
|
|
717
|
+
settings,
|
|
718
|
+
targetNs,
|
|
719
|
+
(childIds) => modelsForWithHistory(childIds),
|
|
720
|
+
),
|
|
721
|
+
{ authority: 'trusted-host' },
|
|
722
|
+
)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
// Capture the delegated prompt before agent/request: the Host commits the
|
|
726
|
+
// current step's user message only after the request waterfall.
|
|
727
|
+
ctx.on('agent/pre-step', async ({ agent, turn, step }, next) => {
|
|
728
|
+
const decision = await next()
|
|
729
|
+
if (decision.kind !== 'enter') return decision
|
|
730
|
+
if (isSubagent(agent) && turn === 1 && step === 1
|
|
731
|
+
&& agent?.options?.modelRole === undefined && !firstStepText.has(agent)) {
|
|
732
|
+
const text = promptTextOf(decision.messages)
|
|
733
|
+
if (text !== '') firstStepText.set(agent, text.slice(-6_000))
|
|
734
|
+
}
|
|
735
|
+
return decision
|
|
736
|
+
}, { prepend: true })
|
|
737
|
+
|
|
738
|
+
ctx.on('session/event', (session, event) => {
|
|
739
|
+
if (event?.type === 'subagent/descriptor' && typeof event.data?.label === 'string') {
|
|
740
|
+
subagentLabels.set(session, event.data.label)
|
|
741
|
+
}
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
// Prepending makes this router the outermost agent/request listener: it
|
|
745
|
+
// observes every other listener's result through next() and applies its
|
|
746
|
+
// profile last. It only touches delegated children.
|
|
747
|
+
ctx.on('agent/request', async ({ agent, signal }, next) => {
|
|
748
|
+
const current = await next()
|
|
749
|
+
rememberChildModel(agent, current)
|
|
750
|
+
const cfg = routingConfig
|
|
751
|
+
if (!cfg.routing.enabled || cfg.profiles.size === 0) return current
|
|
752
|
+
if (!isSubagent(agent)) return current
|
|
753
|
+
if (agent?.options?.modelRole !== undefined) return current
|
|
754
|
+
if (planModeActive(agent?.session?.events)) return current
|
|
755
|
+
|
|
756
|
+
const label = labelOf(agent)
|
|
757
|
+
const captured = firstStepText.get(agent)
|
|
758
|
+
const text = typeof captured === 'string' && captured !== '' ? captured : taskTextOf(agent)
|
|
759
|
+
if ((label ?? '') === '' && (text ?? '') === '') return current
|
|
760
|
+
|
|
761
|
+
const firstRequest = requestHeaderOf(agent) === undefined
|
|
762
|
+
let decision
|
|
763
|
+
const cached = taskDecisions.get(agent)
|
|
764
|
+
if (cached !== undefined) {
|
|
765
|
+
decision = await cached.promise
|
|
766
|
+
} else {
|
|
767
|
+
if (!firstRequest) return current
|
|
768
|
+
if (cfg.routing.respectExplicitChildModel && childHasExplicitRoute(agent)) return current
|
|
769
|
+
decision = await decisionFor(agent, cfg, label, text, signal)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (decision?.profile === undefined || !cfg.profiles.has(decision.profile.id)) return current
|
|
773
|
+
const route = toRoute(decision.profile)
|
|
774
|
+
if (route === undefined) return current
|
|
775
|
+
if (firstRequest && !(await routeUsable(route, signal))) return current
|
|
776
|
+
const routed = applyRoute(current, route)
|
|
777
|
+
rememberChildModel(agent, routed)
|
|
778
|
+
return routed
|
|
779
|
+
}, { prepend: true })
|
|
780
|
+
}
|