dsh-tool-adapt 0.2.2

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/lib/config.js ADDED
@@ -0,0 +1,145 @@
1
+ // Shared config contract for dsh-tool-adapt.
2
+ // Nested runtime shape stays the same as the historical JSON file.
3
+ // Official Settings Card writes the same nested section through path mutate.
4
+
5
+ export const SETTINGS_NS = 'tool-adapt'
6
+ export const MIGRATED_MARK = 'tool-adapt.config.migrated'
7
+
8
+ export const DEFAULT_CONFIG = {
9
+ guard: {
10
+ enabled: true,
11
+ },
12
+ l2: {
13
+ enabled: true,
14
+ excludeModels: ['deepseek-*'],
15
+ block:
16
+ 'DSH tool-call conventions:\n' +
17
+ '- Tool parameters named `sandbox_permissions` / `justification` exist only in sessions that can grant escalation. If they are absent from a tool schema in this session, escalation is impossible here: never send them — the environment will ignore them and the call runs at the session\'s current access level.\n' +
18
+ '- Read a file before editing it; anchor `edit` with an exact `old_string`.\n' +
19
+ '- Only tools in the CURRENT tool list exist. A tool name remembered from earlier history (for example a removed dynamic tool) is gone: if a call returns UNKNOWN_TOOL, never retry that name.\n' +
20
+ '- Error texts are instructions: do exactly what they say.',
21
+ },
22
+ l0: {
23
+ enabled: true,
24
+ remindAfter: 2,
25
+ vetoAfter: 0,
26
+ reminderText:
27
+ 'Loop-breaker: tool {tool} has now failed {count} times in a row with different arguments. Stop retrying. Read the latest error text and follow it exactly. If no fix is obvious, stop and ask the user instead of retrying.',
28
+ vetoText:
29
+ 'Loop-breaker veto: tool {tool} has failed {count} consecutive times, so this call is blocked. Read the latest error text and change your approach, or stop and ask the user before retrying.',
30
+ },
31
+ // UI surface (no pipeline effect). The composer pill stays hidden until the
32
+ // user flips ui.pill on under 设置 → 插件 → ADAPT.
33
+ ui: {
34
+ pill: false,
35
+ },
36
+ }
37
+
38
+ export function isPlainObject(v) {
39
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
40
+ }
41
+
42
+ function checkText(errors, path, value, fallback, cap) {
43
+ if (value === undefined) return fallback
44
+ if (typeof value === 'string' && value.length > 0 && value.length <= cap) return value
45
+ errors.push(path + ' must be a non-empty string <= ' + cap + ' chars')
46
+ return fallback
47
+ }
48
+
49
+ function checkBool(errors, path, value, fallback) {
50
+ if (value === undefined) return fallback
51
+ if (typeof value === 'boolean') return value
52
+ errors.push(path + ' must be a boolean')
53
+ return fallback
54
+ }
55
+
56
+ function checkInt(errors, path, value, fallback, min, max) {
57
+ if (value === undefined) return fallback
58
+ if (Number.isInteger(value) && value >= min && value <= max) return value
59
+ errors.push(path + ' must be an integer in [' + min + ', ' + max + ']')
60
+ return fallback
61
+ }
62
+
63
+ function checkModelPatterns(errors, path, value, fallback) {
64
+ if (value === undefined) return fallback.slice()
65
+ if (!Array.isArray(value) || value.length > 50) {
66
+ errors.push(path + ' must be an array of <= 50 entries')
67
+ return fallback.slice()
68
+ }
69
+ const out = []
70
+ for (const entry of value) {
71
+ if (typeof entry === 'string' && /^[A-Za-z0-9.*_-]{1,64}$/.test(entry)) out.push(entry)
72
+ else errors.push(path + ' entries must match [A-Za-z0-9.*_-]{1,64}')
73
+ }
74
+ return out
75
+ }
76
+
77
+ export function validateConfig(raw) {
78
+ if (!isPlainObject(raw)) return { ok: false, errors: ['config must be a JSON object'] }
79
+ const errors = []
80
+ for (const key of Object.keys(raw)) {
81
+ if (key !== 'guard' && key !== 'l2' && key !== 'l0' && key !== 'ui') errors.push('unknown top-level key: ' + key)
82
+ }
83
+ const d = DEFAULT_CONFIG
84
+ const guardRaw = isPlainObject(raw.guard) ? raw.guard : {}
85
+ const l2raw = isPlainObject(raw.l2) ? raw.l2 : {}
86
+ const l0raw = isPlainObject(raw.l0) ? raw.l0 : {}
87
+ const uiraw = isPlainObject(raw.ui) ? raw.ui : {}
88
+ if (raw.guard !== undefined && !isPlainObject(raw.guard)) errors.push('guard must be an object')
89
+ if (raw.l2 !== undefined && !isPlainObject(raw.l2)) errors.push('l2 must be an object')
90
+ if (raw.l0 !== undefined && !isPlainObject(raw.l0)) errors.push('l0 must be an object')
91
+ if (raw.ui !== undefined && !isPlainObject(raw.ui)) errors.push('ui must be an object')
92
+ const config = {
93
+ guard: {
94
+ enabled: checkBool(errors, 'guard.enabled', guardRaw.enabled, d.guard.enabled),
95
+ },
96
+ l2: {
97
+ enabled: checkBool(errors, 'l2.enabled', l2raw.enabled, d.l2.enabled),
98
+ excludeModels: checkModelPatterns(errors, 'l2.excludeModels', l2raw.excludeModels, d.l2.excludeModels),
99
+ block: checkText(errors, 'l2.block', l2raw.block, d.l2.block, 4000),
100
+ },
101
+ l0: {
102
+ enabled: checkBool(errors, 'l0.enabled', l0raw.enabled, d.l0.enabled),
103
+ remindAfter: checkInt(errors, 'l0.remindAfter', l0raw.remindAfter, d.l0.remindAfter, 2, 20),
104
+ vetoAfter: checkInt(errors, 'l0.vetoAfter', l0raw.vetoAfter, d.l0.vetoAfter, 0, 20),
105
+ reminderText: checkText(errors, 'l0.reminderText', l0raw.reminderText, d.l0.reminderText, 2000),
106
+ vetoText: checkText(errors, 'l0.vetoText', l0raw.vetoText, d.l0.vetoText, 2000),
107
+ },
108
+ ui: {
109
+ pill: checkBool(errors, 'ui.pill', uiraw.pill, d.ui.pill),
110
+ },
111
+ }
112
+ if (errors.length > 0) return { ok: false, errors }
113
+ return { ok: true, config }
114
+ }
115
+
116
+ export function cloneConfig(config) {
117
+ return JSON.parse(JSON.stringify(config || DEFAULT_CONFIG))
118
+ }
119
+
120
+ export function userSectionEmpty(user) {
121
+ if (!isPlainObject(user)) return true
122
+ return Object.keys(user).length === 0
123
+ }
124
+
125
+ export function parseExcludeModels(text) {
126
+ if (typeof text !== 'string') return []
127
+ return text.split(',').map((s) => s.trim()).filter(Boolean)
128
+ }
129
+
130
+ export function formatExcludeModels(list) {
131
+ return Array.isArray(list) ? list.join(',') : ''
132
+ }
133
+
134
+ export function fieldAt(section, path) {
135
+ let cur = section
136
+ for (const key of path) {
137
+ if (!isPlainObject(cur) || !(key in cur)) return undefined
138
+ cur = cur[key]
139
+ }
140
+ return cur
141
+ }
142
+
143
+ export function userOverridesPath(user, path) {
144
+ return fieldAt(user, path) !== undefined
145
+ }
package/lib/index.js ADDED
@@ -0,0 +1,512 @@
1
+ // dsh-tool-adapt — host half (official bundle form)
2
+ //
3
+ // Adaptation layer for foreign models. ONE RULE (first principles): in a
4
+ // session where escalation is impossible ("dead" state — sandbox already at
5
+ // danger-full-access, or approval policy "never"), the environment stops lying
6
+ // to the model and stops punishing it:
7
+ //
8
+ // guard.remove the model-facing tool schemas no longer offer the
9
+ // `sandbox_permissions` / `justification` parameters at all —
10
+ // dead buttons are removed from the assembly copy only; the
11
+ // registry schemas are never touched and legal sessions keep
12
+ // the official wording.
13
+ // guard.strip if a call still arrives carrying those fields (training
14
+ // habit, stale compacted context, mid-session state switch),
15
+ // the fields are stripped at `tools/execute` and the call runs
16
+ // under the session's standing policy. No error, no loop.
17
+ //
18
+ // Both halves derive from ONE predicate, `escalationDeadState(session)`,
19
+ // re-evaluated at every assembly/call, so a mode/policy switch takes effect on
20
+ // the very next step. Text-level persuasion has been REMOVED: evidence showed
21
+ // pipio models ignore all of it — the parameter NAME existing in the schema is
22
+ // what triggers their habit, and removing it is the only signal that works.
23
+ //
24
+ // Two supporting aspects remain, both optional:
25
+ // L2 DSH tool-call conventions — a system-prompt section injected only for
26
+ // non-native model families (default: everything except `deepseek-*`),
27
+ // gated in the `system-prompt/assemble` waterfall via the `{{model}}`
28
+ // assembly variable with session.requestHeader()/requestContext()
29
+ // fallbacks.
30
+ // L0 failure-count loop-breaker — counts CONSECUTIVE same-tool failures
31
+ // with ANY arguments (per agent), injects a reminder at `remindAfter`
32
+ // and can veto further calls at `vetoAfter` (0 = veto off).
33
+ //
34
+ // Config (from the mounting row, resolved against process.cwd()):
35
+ // configFile -> the JSON config file (defaults:
36
+ // <cwd>/plugins/tool-adapt.config.json, keeping the legacy
37
+ // profile location so existing configs migrate unchanged).
38
+ // Strictly validated before apply/persist; changes are hot.
39
+ //
40
+ // Safety notes:
41
+ // - Every listener is wrapped; agent-less calls pass through; the education
42
+ // layer must never break the pipeline.
43
+ // - The strip half reassigns `exec.arguments` with a cleaned copy during
44
+ // `tools/execute`. The arguments object itself is deep-frozen by the
45
+ // registry, so we replace the property rather than delete keys.
46
+ // - In legal states (confined + approval=ask) the official escalation flow
47
+ // is untouched: nothing is removed, nothing is stripped.
48
+
49
+ import path from 'node:path'
50
+ import { randomUUID } from 'node:crypto'
51
+ import Schema from '@deepseek-ai/schemastery'
52
+ import {
53
+ DEFAULT_CONFIG,
54
+ SETTINGS_NS,
55
+ cloneConfig,
56
+ userSectionEmpty,
57
+ validateConfig,
58
+ } from './config.js'
59
+
60
+ export { DEFAULT_CONFIG, SETTINGS_NS, validateConfig } from './config.js'
61
+
62
+ export const name = 'dsh-tool-adapt'
63
+ export const inject = ['webServer', 'fs', 'systemPrompt']
64
+
65
+ function createSettingsSchema() {
66
+ return Schema.object({
67
+ guard: Schema.object({
68
+ enabled: Schema.boolean().default(DEFAULT_CONFIG.guard.enabled),
69
+ }).default(cloneConfig(DEFAULT_CONFIG.guard)),
70
+ l2: Schema.object({
71
+ enabled: Schema.boolean().default(DEFAULT_CONFIG.l2.enabled),
72
+ excludeModels: Schema.array(Schema.string().pattern(/^[A-Za-z0-9.*_-]{1,64}$/)).max(50).default(DEFAULT_CONFIG.l2.excludeModels.slice()),
73
+ block: Schema.string().min(1).max(4000).default(DEFAULT_CONFIG.l2.block),
74
+ }).default(cloneConfig(DEFAULT_CONFIG.l2)),
75
+ l0: Schema.object({
76
+ enabled: Schema.boolean().default(DEFAULT_CONFIG.l0.enabled),
77
+ remindAfter: Schema.number().step(1).min(2).max(20).default(DEFAULT_CONFIG.l0.remindAfter),
78
+ vetoAfter: Schema.number().step(1).min(0).max(20).default(DEFAULT_CONFIG.l0.vetoAfter),
79
+ reminderText: Schema.string().min(1).max(2000).default(DEFAULT_CONFIG.l0.reminderText),
80
+ vetoText: Schema.string().min(1).max(2000).default(DEFAULT_CONFIG.l0.vetoText),
81
+ }).default(cloneConfig(DEFAULT_CONFIG.l0)),
82
+ ui: Schema.object({
83
+ pill: Schema.boolean().default(DEFAULT_CONFIG.ui.pill),
84
+ }).default(cloneConfig(DEFAULT_CONFIG.ui)),
85
+ })
86
+ }
87
+
88
+ async function readLegacyFile(ctx, configFile) {
89
+ try {
90
+ const target = await ctx.fs.resolve(configFile)
91
+ const text = await ctx.fs.readText(target)
92
+ return { ok: true, text, target }
93
+ } catch (err) {
94
+ const msg = String((err && err.message) || err)
95
+ if (/not found|ENOENT/i.test(msg)) return { ok: false, missing: true, error: msg }
96
+ return { ok: false, missing: false, error: msg }
97
+ }
98
+ }
99
+
100
+ async function loadLegacyFile(ctx, configFile, state) {
101
+ const file = await readLegacyFile(ctx, configFile)
102
+ if (file.missing) return
103
+ if (!file.ok) {
104
+ state.fileError = file.error
105
+ return
106
+ }
107
+ try {
108
+ const validated = validateConfig(JSON.parse(file.text))
109
+ if (validated.ok) state.config = validated.config
110
+ else state.fileError = (validated.errors || []).join('; ')
111
+ } catch (err) {
112
+ state.fileError = String((err && err.message) || err)
113
+ }
114
+ }
115
+
116
+ async function migrateLegacyConfig(ctx, configFile, settings, state) {
117
+ try {
118
+ const described = typeof settings.describe === 'function' ? settings.describe() : []
119
+ const current = Array.isArray(described) ? described.find((item) => item && item.ns === SETTINGS_NS) : undefined
120
+ if (current && !userSectionEmpty(current.user)) {
121
+ state.migrated = true
122
+ return
123
+ }
124
+ const file = await readLegacyFile(ctx, configFile)
125
+ if (file.missing) return
126
+ if (!file.ok) {
127
+ state.fileError = file.error
128
+ return
129
+ }
130
+ const validated = validateConfig(JSON.parse(file.text))
131
+ if (!validated.ok) {
132
+ state.fileError = (validated.errors || []).join('; ')
133
+ return
134
+ }
135
+ await settings.replace(SETTINGS_NS, validated.config)
136
+ state.migrated = true
137
+ } catch (err) {
138
+ const msg = String((err && err.message) || err)
139
+ if (!/not found|ENOENT/i.test(msg)) state.fileError = msg
140
+ }
141
+ }
142
+
143
+ // ── helpers ──────────────────────────────────────────────────────────────────
144
+
145
+ function wildcardToRegExp(pattern) {
146
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&')
147
+ return new RegExp('^' + escaped.replaceAll('*', '.*') + '$')
148
+ }
149
+
150
+ function fill(template, tool, count) {
151
+ return String(template)
152
+ .split('{tool}').join(String(tool))
153
+ .split('{count}').join(String(count))
154
+ }
155
+
156
+ function makeNotice(text, summary) {
157
+ return {
158
+ id: randomUUID(),
159
+ role: 'user',
160
+ content: [{ type: 'text', text }],
161
+ source: { kind: 'plugin', plugin: 'dsh-tool-adapt', form: 'notice', summary },
162
+ }
163
+ }
164
+
165
+ function prepend(ours, theirs) {
166
+ return [ours, ...(theirs ?? [])]
167
+ }
168
+
169
+ // ── plugin ───────────────────────────────────────────────────────────────────
170
+
171
+ export function apply(ctx, config) {
172
+ const configFile = (config && config.configFile)
173
+ ? path.resolve(process.cwd(), String(config.configFile))
174
+ : path.resolve(process.cwd(), 'plugins', 'tool-adapt.config.json')
175
+
176
+ const entry = cloneConfig(DEFAULT_CONFIG)
177
+ let source = () => entry
178
+ const state = {
179
+ fileError: null,
180
+ settingsReady: false,
181
+ migrated: false,
182
+ get config() {
183
+ return source()
184
+ },
185
+ set config(next) {
186
+ source = () => next
187
+ },
188
+ }
189
+
190
+ if (ctx.get('settings') === undefined) {
191
+ void loadLegacyFile(ctx, configFile, state)
192
+ }
193
+
194
+ ctx.inject(['settings'], (sctx) => {
195
+ try {
196
+ const schema = createSettingsSchema()
197
+ const scope = sctx.settings.register(SETTINGS_NS, schema, {
198
+ base: entry,
199
+ applies: 'live',
200
+ validate: (value) => {
201
+ const validated = validateConfig(value)
202
+ if (!validated.ok) throw new Error((validated.errors || []).join('; '))
203
+ },
204
+ })
205
+ source = () => {
206
+ const resolved = scope.get()
207
+ const validated = validateConfig(resolved)
208
+ return validated.ok ? validated.config : entry
209
+ }
210
+ state.settingsReady = true
211
+ sctx.effect(() => scope.watch(() => {}), 'dsh-tool-adapt: settings watch')
212
+ sctx.effect(() => () => {
213
+ state.settingsReady = false
214
+ source = () => entry
215
+ }, 'dsh-tool-adapt: settings fallback')
216
+ void migrateLegacyConfig(sctx, configFile, sctx.settings, state)
217
+ } catch (err) {
218
+ state.fileError = String((err && err.message) || err)
219
+ source = () => entry
220
+ }
221
+ })
222
+
223
+ const chains = new WeakMap() // agent -> { tool, count } (consecutive failures, any args)
224
+
225
+ // THE predicate: is escalation impossible in this session, and why?
226
+ function escalationDeadState(session) {
227
+ if (session === undefined || session === null) return undefined
228
+ const sp = ctx.get('sandboxPolicy')
229
+ if (sp !== undefined) {
230
+ let mode
231
+ try { mode = sp.resolve({ session: session }).mode } catch (_) { mode = undefined }
232
+ if (mode === 'danger-full-access') return { kind: 'full-access' }
233
+ }
234
+ const ap = ctx.get('approval')
235
+ if (ap !== undefined) {
236
+ let policy
237
+ try {
238
+ policy = typeof ap.effectivePolicy === 'function' ? ap.effectivePolicy(session) : undefined
239
+ } catch (_) { policy = undefined }
240
+ if (policy === 'never') return { kind: 'never' }
241
+ }
242
+ return undefined
243
+ }
244
+
245
+ function removeEscalationParams(tools) {
246
+ if (!Array.isArray(tools)) return tools
247
+ let anyChanged = false
248
+ const next = tools.map((tool) => {
249
+ if (!tool || !isPlainObject(tool.parameters) || !isPlainObject(tool.parameters.properties)) return tool
250
+ const props = tool.parameters.properties
251
+ if (!('sandbox_permissions' in props) && !('justification' in props)) return tool
252
+ const required = Array.isArray(tool.parameters.required) ? tool.parameters.required : []
253
+ const kept = {}
254
+ let dropped = false
255
+ for (const key of Object.keys(props)) {
256
+ if ((key === 'sandbox_permissions' || key === 'justification') && !required.includes(key)) {
257
+ dropped = true
258
+ } else {
259
+ kept[key] = props[key]
260
+ }
261
+ }
262
+ if (!dropped) return tool
263
+ anyChanged = true
264
+ return { ...tool, parameters: { ...tool.parameters, properties: kept } }
265
+ })
266
+ return anyChanged ? next : tools
267
+ }
268
+
269
+ function stripEscalationArgs(exec) {
270
+ const args = exec.arguments
271
+ if (!isPlainObject(args)) return false
272
+ if (!('sandbox_permissions' in args) && !('justification' in args)) return false
273
+ const session = exec.agent && exec.agent.session
274
+ if (escalationDeadState(session) === undefined) return false
275
+ const cleaned = {}
276
+ for (const key of Object.keys(args)) {
277
+ if (key !== 'sandbox_permissions' && key !== 'justification') cleaned[key] = args[key]
278
+ }
279
+ exec.arguments = cleaned
280
+ return true
281
+ }
282
+
283
+ ctx.on('tools/execute', (exec, next) => {
284
+ try {
285
+ if (state.config.guard.enabled) stripEscalationArgs(exec)
286
+ } catch (_) {
287
+ // never break the pipeline
288
+ }
289
+ return next()
290
+ })
291
+
292
+ // ── L0 veto: pre-execute fuse ─────────────────────────────────────────────
293
+
294
+ ctx.on('tools/pre-execute', (exec, next) => {
295
+ try {
296
+ const cfg = state.config.l0
297
+ if (cfg.enabled && cfg.vetoAfter > 0 && exec.agent) {
298
+ const chain = chains.get(exec.agent)
299
+ if (chain !== undefined && chain.tool === exec.name && chain.count >= cfg.vetoAfter) {
300
+ return { kind: 'deny', reason: fill(cfg.vetoText, exec.name, chain.count) }
301
+ }
302
+ }
303
+ } catch (_) {
304
+ // education layer must never break the pipeline
305
+ }
306
+ return next()
307
+ })
308
+
309
+ // ── L0: failure counting + reminder injection ─────────────────────────────
310
+
311
+ function observeFailure(exec, result) {
312
+ if (!exec.agent) return undefined
313
+ const cfg = state.config.l0
314
+ if (!cfg.enabled) return undefined
315
+ const failed = !!(result && result.isError === true)
316
+ const chain = chains.get(exec.agent)
317
+ if (!failed) {
318
+ if (chain !== undefined) chains.delete(exec.agent)
319
+ return undefined
320
+ }
321
+ const count = chain !== undefined && chain.tool === exec.name ? chain.count + 1 : 1
322
+ chains.set(exec.agent, { tool: exec.name, count })
323
+ // Escalate: remind on the threshold failure and keep reminding on each
324
+ // further consecutive failure within a bounded window.
325
+ if (count < cfg.remindAfter || count > cfg.remindAfter + 4) return undefined
326
+ return makeNotice(fill(cfg.reminderText, exec.name, count), exec.name + ' × ' + count)
327
+ }
328
+
329
+ ctx.on('tools/post-execute', async (exec, result, next) => {
330
+ let notice
331
+ try { notice = observeFailure(exec, result) } catch (_) { notice = undefined }
332
+ const downstream = await next()
333
+ if (notice === undefined) return downstream
334
+ if (downstream.kind === 'block') {
335
+ return {
336
+ kind: 'block',
337
+ feedback: downstream.feedback,
338
+ additionalContexts: prepend(notice, downstream.additionalContexts),
339
+ }
340
+ }
341
+ return { ...downstream, additionalContexts: prepend(notice, downstream.additionalContexts) }
342
+ })
343
+
344
+ // Reset an agent's chain when a human message starts the step (mirrors
345
+ // repeat-tool-reminder's reset rule).
346
+ ctx.on('agent/pre-step', ({ agent, messages }, next) => {
347
+ try {
348
+ if (agent && Array.isArray(messages) && messages.some((m) => m && m.source && m.source.kind === 'user')) {
349
+ chains.delete(agent)
350
+ }
351
+ } catch (_) {}
352
+ return next()
353
+ })
354
+
355
+ // ── L2: conventions section, gated by model family ────────────────────────
356
+
357
+ function readModelFromSession(context) {
358
+ try {
359
+ const session = context && context.agent && context.agent.session
360
+ if (!session) return undefined
361
+ if (typeof session.requestHeader === 'function') {
362
+ const h = session.requestHeader()
363
+ if (h && h.config && typeof h.config.model === 'string') return h.config.model
364
+ }
365
+ if (typeof session.requestContext === 'function') {
366
+ const rc = session.requestContext()
367
+ if (rc && typeof rc.model === 'string') return rc.model
368
+ }
369
+ } catch (_) {}
370
+ return undefined
371
+ }
372
+
373
+ function isExcludedModel(model, patterns) {
374
+ for (const pattern of patterns) {
375
+ if (wildcardToRegExp(pattern).test(model)) return true
376
+ }
377
+ return false
378
+ }
379
+
380
+ ctx.systemPrompt.section({
381
+ name: 'adapt:conventions',
382
+ order: 106,
383
+ text: (context) => {
384
+ const cfg = state.config.l2
385
+ if (!cfg || cfg.enabled === false) return ''
386
+ const model = readModelFromSession(context)
387
+ if (model === undefined) return '' // unknown here; the waterfall decides
388
+ return isExcludedModel(model, cfg.excludeModels) ? '' : cfg.block
389
+ },
390
+ })
391
+
392
+ ctx.on('system-prompt/assemble', async (assembly, context, next) => {
393
+ const result = await next()
394
+ try {
395
+ const cfg = state.config
396
+ // guard.remove is independent of L2: it reflects session reality, not
397
+ // model-family policy.
398
+ if (cfg.guard.enabled) {
399
+ const session = context.agent && context.agent.session
400
+ if (escalationDeadState(session) !== undefined) {
401
+ result.tools = removeEscalationParams(result.tools)
402
+ }
403
+ }
404
+ const l2 = cfg.l2
405
+ if (!l2 || l2.enabled === false) return result
406
+ let model = result.variables ? result.variables.model : undefined
407
+ if (!model) model = readModelFromSession(context)
408
+ if (!model) return result // unknown: leave the assembly as the provider built it
409
+ const excluded = isExcludedModel(model, l2.excludeModels)
410
+ const present = result.sections.some((s) => s.name === 'adapt:conventions')
411
+ if (excluded && present) {
412
+ result.sections = result.sections.filter((s) => s.name !== 'adapt:conventions')
413
+ } else if (!excluded && !present) {
414
+ // Provider skipped it (model unknown at evaluation time, e.g. the first
415
+ // step of a fresh session); restore the block.
416
+ result.sections = [...result.sections, { name: 'adapt:conventions', text: l2.block }]
417
+ }
418
+ return result
419
+ } catch (_) {
420
+ return result
421
+ }
422
+ })
423
+
424
+ // ── web routes (loopback + same-origin fenced) ────────────────────────────
425
+
426
+ function json(res, code, data) {
427
+ const body = JSON.stringify(data)
428
+ res.writeHead(code, {
429
+ 'Content-Type': 'application/json; charset=utf-8',
430
+ 'Cache-Control': 'no-store',
431
+ })
432
+ res.end(body)
433
+ }
434
+
435
+ async function readBody(req) {
436
+ const chunks = []
437
+ let total = 0
438
+ for await (const chunk of req) {
439
+ total += chunk.length
440
+ if (total > 8192) throw Object.assign(new Error('request body too large'), { status: 413 })
441
+ chunks.push(chunk)
442
+ }
443
+ return Buffer.concat(chunks).toString('utf8')
444
+ }
445
+
446
+ function originAllowed(req) {
447
+ const origin = req.headers.origin
448
+ if (!origin) return true
449
+ const host = req.headers.host || ''
450
+ const base = /^https?:\/\/([^/]+)/i.exec(origin)
451
+ if (!base) return false
452
+ return base[1] === host
453
+ }
454
+
455
+ function hostAllowed(req) {
456
+ let host = (req.headers.host || '').split(':')[0].toLowerCase()
457
+ host = host.replace(/^\[|\]$/g, '')
458
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1'
459
+ }
460
+
461
+ ctx.effect(() => ctx.webServer.register({
462
+ kind: 'exact',
463
+ path: '/api/tool-adapt/status',
464
+ handler: (req, res) => {
465
+ try {
466
+ json(res, 200, {
467
+ ok: true,
468
+ config: state.config,
469
+ configFile,
470
+ fileError: state.fileError,
471
+ settingsReady: !!state.settingsReady,
472
+ migrated: !!state.migrated,
473
+ })
474
+ } catch (err) {
475
+ json(res, 500, { ok: false, error: String((err && err.message) || err) })
476
+ }
477
+ },
478
+ }), 'dsh-tool-adapt: status route')
479
+
480
+ ctx.effect(() => ctx.webServer.register({
481
+ kind: 'exact',
482
+ path: '/api/tool-adapt/set',
483
+ handler: async (req, res) => {
484
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'POST required' })
485
+ if (!hostAllowed(req)) return json(res, 403, { ok: false, error: 'host not allowed' })
486
+ if (!originAllowed(req)) return json(res, 403, { ok: false, error: 'origin not allowed' })
487
+ try {
488
+ let body
489
+ try {
490
+ body = JSON.parse((await readBody(req)) || '{}')
491
+ } catch (err) {
492
+ if (err && err.status === 413) return json(res, 413, { ok: false, error: 'request body too large' })
493
+ return json(res, 400, { ok: false, error: 'invalid JSON body' })
494
+ }
495
+ const validated = validateConfig(body)
496
+ if (!validated.ok) return json(res, 400, { ok: false, errors: validated.errors })
497
+ const liveSettings = ctx.get('settings')
498
+ if (liveSettings !== undefined) {
499
+ await liveSettings.replace(SETTINGS_NS, validated.config)
500
+ } else {
501
+ const target = await ctx.fs.resolve(configFile)
502
+ await ctx.fs.writeText(target, JSON.stringify(validated.config, null, 2) + '\n')
503
+ state.config = validated.config
504
+ }
505
+ state.fileError = null
506
+ json(res, 200, { ok: true, applied: state.config })
507
+ } catch (err) {
508
+ json(res, 500, { ok: false, error: String((err && err.message) || err) })
509
+ }
510
+ },
511
+ }), 'dsh-tool-adapt: set route')
512
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "dsh-tool-adapt",
3
+ "version": "0.2.2",
4
+ "description": "EN: Compatibility and safety adaptation layer for non-DeepSeek models in DeepSeek Harness Web. ZH: 面向 DeepSeek Harness Web 非 DeepSeek 模型的兼容与安全适配层。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib/",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test"
20
+ },
21
+ "dependencies": {
22
+ "@deepseek-ai/schemastery": "^3.18.1"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web",
30
+ "inject": ["@deepseek-ai/dsh-api-remotes"]
31
+ }
32
+ },
33
+ "repository": { "type": "git", "url": "git+https://github.com/YrracOwl/dsh-tool-adapt.git" },
34
+ "homepage": "https://github.com/YrracOwl/dsh-tool-adapt#readme",
35
+ "bugs": { "url": "https://github.com/YrracOwl/dsh-tool-adapt/issues" },
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=18"
39
+ }
40
+ }