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/LICENSE +21 -0
- package/README.md +55 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +841 -0
- package/lib/config.js +145 -0
- package/lib/index.js +512 -0
- package/package.json +40 -0
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
|
+
}
|