@deepblend/dsh-blender-tool 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +277 -0
- package/lib/render-tools.js +579 -0
- package/lib/shared.js +458 -0
- package/lib/tools.js +988 -0
- package/lib/visual-tools.js +444 -0
- package/package.json +24 -0
package/lib/shared.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the DeepBlend model-visible tools (SPEC §11).
|
|
3
|
+
*
|
|
4
|
+
* Plane: Agent preset — this package contributes tools to ONE session's model
|
|
5
|
+
* and publishes no service, which is what makes it legal as a preset row
|
|
6
|
+
* (SPEC §4.4).
|
|
7
|
+
*
|
|
8
|
+
* WHY A SHARED MODULE
|
|
9
|
+
* -------------------
|
|
10
|
+
* Every DeepBlend tool has the same four obligations, and each one is easy to
|
|
11
|
+
* get subtly wrong in a different way per tool:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Resolve `blenderStudio` per call.** The host bundle is a process-level
|
|
14
|
+
* composition change that only takes effect on the next profile boot, so a
|
|
15
|
+
* half-installed deployment must still be able to open a session and
|
|
16
|
+
* diagnose itself. The absence is reported as a stable
|
|
17
|
+
* `BLENDER_RUNTIME_UNAVAILABLE` result, never as a thrown error.
|
|
18
|
+
* 2. **Return canonical JSON plus readable text.** SPEC §11.1 requires both: the
|
|
19
|
+
* model reads the prose, and nothing downstream may have to parse it.
|
|
20
|
+
* 3. **Turn every failure into a stable `errorCode`.** A tool that throws leaves
|
|
21
|
+
* the model with a stack trace and no way to branch.
|
|
22
|
+
* 4. **Never let a failure look like a success.** Every error result carries
|
|
23
|
+
* `ok: false` AND a non-empty `message`.
|
|
24
|
+
*
|
|
25
|
+
* Owner: DeepBlend Studio — M1
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { BlenderError, BlenderErrorCode, toCanonicalFailure } from '@deepblend/dsh-blender-contracts'
|
|
29
|
+
|
|
30
|
+
/** The host service every DeepBlend tool consumes. */
|
|
31
|
+
export const STUDIO_SERVICE = 'blenderStudio'
|
|
32
|
+
|
|
33
|
+
/** The bundle an operator must compose when the host half is missing. */
|
|
34
|
+
export const HOST_BUNDLE = '@deepblend/dsh-blender-bundle'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the studio service, or describe precisely why it is unavailable.
|
|
38
|
+
*
|
|
39
|
+
* `attachments` comes back alongside it because a tool that returns an IMAGE has to
|
|
40
|
+
* persist it before the result is materialized (M2 decision D29: the reference is
|
|
41
|
+
* the value, the bytes never enter the transcript). It is optional — a deployment
|
|
42
|
+
* without an attachment store still gets every non-image tool.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
45
|
+
* @returns {{ studio: any, attachments: any }|{ unavailable: { text: string, data: object } }}
|
|
46
|
+
*/
|
|
47
|
+
export function resolveStudio(ctx) {
|
|
48
|
+
const studio = ctx.get(STUDIO_SERVICE)
|
|
49
|
+
if (studio !== undefined) return { studio, attachments: ctx.get('attachments') }
|
|
50
|
+
const error = new BlenderError(
|
|
51
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
52
|
+
'The DeepBlend host half is not composed in this process, so no Blender runtime is available. ' +
|
|
53
|
+
`Add "${HOST_BUNDLE}" to the profile's dsh.profile.bundles and restart the profile.`,
|
|
54
|
+
{ detail: { missingService: STUDIO_SERVICE, bundle: HOST_BUNDLE } },
|
|
55
|
+
)
|
|
56
|
+
return {
|
|
57
|
+
unavailable: {
|
|
58
|
+
text:
|
|
59
|
+
`DeepBlend host services are unavailable.\n` +
|
|
60
|
+
`errorCode: ${error.code}\n` +
|
|
61
|
+
`message: ${error.message}`,
|
|
62
|
+
data: error.toJSON(),
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Persist a rendered PNG and shape the attachment reference for a tool result.
|
|
69
|
+
*
|
|
70
|
+
* MEASURED CONSTRAINTS THIS FUNCTION ENCODES (M2 probe, `runtime-audit.md` §7.2)
|
|
71
|
+
* -----------------------------------------------------------------------------
|
|
72
|
+
* 1. `saveImage` is async IO, so it MUST run here in `execute` — never in
|
|
73
|
+
* `output.render`, which `defineTool` declares pure because a UI calls it during
|
|
74
|
+
* live streaming AND during a session-log replay.
|
|
75
|
+
* 2. What goes into the canonical value is a REFERENCE (ids and dimensions), never
|
|
76
|
+
* bytes: the result is snapshotted as lossless JSON, and a `Uint8Array` would
|
|
77
|
+
* make the whole tool call fail.
|
|
78
|
+
*
|
|
79
|
+
* @param {any} attachments - the host attachment service, or undefined.
|
|
80
|
+
* @param {Buffer|Uint8Array} bytes
|
|
81
|
+
* @param {string} name
|
|
82
|
+
* @returns {Promise<{ image: object|null, note: string|null }>}
|
|
83
|
+
*/
|
|
84
|
+
export async function persistImage(attachments, bytes, name) {
|
|
85
|
+
if (attachments === undefined || typeof attachments.saveImage !== 'function') {
|
|
86
|
+
return {
|
|
87
|
+
image: null,
|
|
88
|
+
note:
|
|
89
|
+
'The rendered image could not be attached to this result because the host has no attachment store, ' +
|
|
90
|
+
'so the model cannot see it here. Its path is in the payload; read it with a file-read tool instead.',
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const ref = await attachments.saveImage({ data: bytes, mediaType: 'image/png', name })
|
|
94
|
+
return {
|
|
95
|
+
image: {
|
|
96
|
+
attachmentId: String(ref.attachmentId),
|
|
97
|
+
mediaType: String(ref.mediaType),
|
|
98
|
+
bytes: Number(ref.bytes),
|
|
99
|
+
width: Number(ref.width),
|
|
100
|
+
height: Number(ref.height),
|
|
101
|
+
name: typeof ref.name === 'string' ? ref.name : name,
|
|
102
|
+
},
|
|
103
|
+
note: null,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render a canonical success envelope as model-readable text.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} headline
|
|
111
|
+
* @param {object} data
|
|
112
|
+
* @param {{ notes?: string[], warnings?: object[] }} [options]
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function renderSuccess(headline, data, options = {}) {
|
|
116
|
+
const lines = [headline, '']
|
|
117
|
+
for (const note of options.notes ?? []) lines.push(note)
|
|
118
|
+
if ((options.notes ?? []).length > 0) lines.push('')
|
|
119
|
+
const warnings = options.warnings ?? []
|
|
120
|
+
if (warnings.length > 0) {
|
|
121
|
+
lines.push(`Warnings (${warnings.length}):`)
|
|
122
|
+
for (const entry of warnings) lines.push(` - [${entry.code}] ${entry.message}`)
|
|
123
|
+
lines.push('')
|
|
124
|
+
}
|
|
125
|
+
lines.push('Canonical JSON:')
|
|
126
|
+
lines.push(JSON.stringify(data, null, 2))
|
|
127
|
+
return lines.join('\n')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Render a failure as model-readable text plus a stable code.
|
|
132
|
+
*
|
|
133
|
+
* @param {unknown} cause
|
|
134
|
+
* @param {string} fallbackCode
|
|
135
|
+
* @returns {{ text: string, data: object }}
|
|
136
|
+
*/
|
|
137
|
+
export function renderFailure(cause, fallbackCode) {
|
|
138
|
+
const failure = toCanonicalFailure(cause, {
|
|
139
|
+
isBlenderError: value => value instanceof BlenderError,
|
|
140
|
+
fallbackCode,
|
|
141
|
+
})
|
|
142
|
+
const lines = [
|
|
143
|
+
'DeepBlend call failed.',
|
|
144
|
+
`errorCode: ${failure.code}`,
|
|
145
|
+
`message: ${failure.message}`,
|
|
146
|
+
]
|
|
147
|
+
if (failure.detail !== null && failure.detail !== undefined) {
|
|
148
|
+
lines.push(`detail: ${JSON.stringify(failure.detail)}`)
|
|
149
|
+
}
|
|
150
|
+
if (failure.stack !== null) {
|
|
151
|
+
// Only an unrecognized failure carries a stack: it is the one case where the
|
|
152
|
+
// caller cannot branch on a code, so the trace is the whole value.
|
|
153
|
+
lines.push('', 'This failure has no stable code — it is a bug. Stack:', failure.stack)
|
|
154
|
+
}
|
|
155
|
+
if (failure.code === BlenderErrorCode.REVISION_CONFLICT) {
|
|
156
|
+
lines.push(
|
|
157
|
+
'',
|
|
158
|
+
'The patch was refused rather than merged, because merging would silently discard whatever changed',
|
|
159
|
+
'since you read the scene. Call blender_scene_get, then re-issue the patch with the revision it returns.',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return { text: lines.join('\n'), data: { ok: false, errorCode: failure.code, message: failure.message, detail: failure.detail } }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The canonical tool output contract. Every DeepBlend tool declares exactly this.
|
|
167
|
+
*
|
|
168
|
+
* `ok` is first so a transcript reads as a verdict before it reads as data.
|
|
169
|
+
*/
|
|
170
|
+
export const TOOL_OUTPUT_SCHEMA = {
|
|
171
|
+
type: 'object',
|
|
172
|
+
additionalProperties: true,
|
|
173
|
+
properties: {
|
|
174
|
+
ok: { type: 'boolean', required: true },
|
|
175
|
+
text: { type: 'string', required: true },
|
|
176
|
+
data: { type: 'object', additionalProperties: true, required: true },
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The `output` block every DeepBlend tool shares. */
|
|
181
|
+
export const TOOL_OUTPUT = {
|
|
182
|
+
schema: TOOL_OUTPUT_SCHEMA,
|
|
183
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The output contract for a tool whose result carries an IMAGE.
|
|
188
|
+
*
|
|
189
|
+
* The `image` field is declared even though the validator does not strip undeclared
|
|
190
|
+
* keys (measured: `createSuccessResult` validates the value and rejects violations,
|
|
191
|
+
* it never drops fields). Declaring it is still worth it, because it makes the shape
|
|
192
|
+
* of an attachment reference checkable at call time instead of discoverable only by
|
|
193
|
+
* the model that receives it.
|
|
194
|
+
*
|
|
195
|
+
* `render` stays pure: it reads the reference out of the canonical value and emits an
|
|
196
|
+
* image block. That is what lets a cold transcript replay show the same image without
|
|
197
|
+
* re-rendering anything (M2 probe question 4).
|
|
198
|
+
*/
|
|
199
|
+
export const TOOL_OUTPUT_WITH_IMAGE = {
|
|
200
|
+
schema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
additionalProperties: true,
|
|
203
|
+
properties: {
|
|
204
|
+
ok: { type: 'boolean', required: true },
|
|
205
|
+
text: { type: 'string', required: true },
|
|
206
|
+
data: { type: 'object', additionalProperties: true, required: true },
|
|
207
|
+
image: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
additionalProperties: true,
|
|
210
|
+
properties: {
|
|
211
|
+
attachmentId: { type: 'string', required: true },
|
|
212
|
+
mediaType: { type: 'string', required: true },
|
|
213
|
+
bytes: { type: 'number', required: true },
|
|
214
|
+
width: { type: 'number', required: true },
|
|
215
|
+
height: { type: 'number', required: true },
|
|
216
|
+
name: { type: 'string' },
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
render: (_args, value) => {
|
|
222
|
+
const blocks = [{ type: 'text', text: value.text }]
|
|
223
|
+
if (value.image !== null && value.image !== undefined) {
|
|
224
|
+
blocks.push({ type: 'image', attachment: value.image })
|
|
225
|
+
}
|
|
226
|
+
return blocks
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A short, model-readable line describing a revision, for use inside a headline.
|
|
232
|
+
* @param {object} summary
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
export function describeRevision(summary) {
|
|
236
|
+
if (summary === undefined || summary === null) return 'unknown revision'
|
|
237
|
+
const parts = [summary.revision ?? '?']
|
|
238
|
+
if (summary.kind !== undefined && summary.kind !== null) parts.push(summary.kind)
|
|
239
|
+
if (summary.checkpoint !== null && summary.checkpoint !== undefined) parts.push('checkpoint saved')
|
|
240
|
+
if (Array.isArray(summary.previews) && summary.previews.length > 0) {
|
|
241
|
+
parts.push(`${summary.previews.length} preview${summary.previews.length === 1 ? '' : 's'}`)
|
|
242
|
+
}
|
|
243
|
+
return parts.join(', ')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Drop every key whose value is `undefined`.
|
|
248
|
+
*
|
|
249
|
+
* This exists because of a real bug, and it is worth stating plainly: a tool
|
|
250
|
+
* receives `args.note === undefined` for an optional parameter the model did not
|
|
251
|
+
* supply, and forwarding `{ note: undefined }` to the host produces a document
|
|
252
|
+
* with an OWN `note` key. The host validates against a JSON Schema, where an
|
|
253
|
+
* absent key means "not supplied" and an own key set to `undefined` means "the
|
|
254
|
+
* caller sent the wrong type" — so a perfectly correct call was rejected with
|
|
255
|
+
* `SCHEMA_PATCH_INVALID: note: expected string, received undefined`.
|
|
256
|
+
*
|
|
257
|
+
* `JSON.stringify` would have dropped the key, which is exactly why the bug
|
|
258
|
+
* survived the serializing paths and only appeared end to end. Filtering at the
|
|
259
|
+
* boundary is the fix: the tool builds documents, so the tool must not emit keys
|
|
260
|
+
* it was not given.
|
|
261
|
+
*
|
|
262
|
+
* @param {Record<string, unknown>} object
|
|
263
|
+
* @returns {Record<string, unknown>}
|
|
264
|
+
*/
|
|
265
|
+
export function definedFields(object) {
|
|
266
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Make a tool result acceptable to the harness's lossless-JSON rule.
|
|
271
|
+
*
|
|
272
|
+
* WHY THIS EXISTS, AND WHY IT IS NOT TIDINESS
|
|
273
|
+
* -------------------------------------------
|
|
274
|
+
* The harness snapshots every tool result with a rule STRICTER than JSON's
|
|
275
|
+
* (`dsh-util-values`' `walkJsonValue`): a value is rejected when it is `undefined`,
|
|
276
|
+
* non-finite, or **negative zero**:
|
|
277
|
+
*
|
|
278
|
+
* if (!Number.isFinite(current) || Object.is(current, -0)) return undefined
|
|
279
|
+
*
|
|
280
|
+
* That last clause is the one that bit. Blender's Python writes `-0.0` for a rotation
|
|
281
|
+
* that is exactly zero, Node parses it back as `-0`, and `JSON.stringify(-0)` is
|
|
282
|
+
* `"0"` — so a number that means the same thing on both sides makes the round trip
|
|
283
|
+
* "lossy" and the whole call is refused with
|
|
284
|
+
* `invalid output: value is not lossless JSON`.
|
|
285
|
+
*
|
|
286
|
+
* It surfaced as an error reported on a SUCCESSFUL commit, which is the worst place
|
|
287
|
+
* for one: the revision had landed, the model was told the call failed, and the
|
|
288
|
+
* message named neither the value nor the field. Reproduced end to end before this
|
|
289
|
+
* fix, in the technical report a patch returns:
|
|
290
|
+
* `data.validation.cameraParameters[1].rotationEuler[1] = -0`.
|
|
291
|
+
*
|
|
292
|
+
* The three cases are treated differently ON PURPOSE:
|
|
293
|
+
*
|
|
294
|
+
* - `-0` becomes `0`. They are the same number; IEEE says so, and only `1/x`
|
|
295
|
+
* distinguishes them. This loses nothing.
|
|
296
|
+
* - `undefined` keys are dropped, and counted. JSON has no representation for them,
|
|
297
|
+
* and a producer that emits one has a bug worth surfacing.
|
|
298
|
+
* - non-finite numbers become `null`, and are counted. They are NOT silently
|
|
299
|
+
* acceptable: a NaN here means something upstream computed nothing, which is how
|
|
300
|
+
* a scene summary once carried `null` bounds without anyone noticing.
|
|
301
|
+
*
|
|
302
|
+
* Repairs are returned rather than swallowed, so the tool can tell the caller. A
|
|
303
|
+
* sanitizer nobody can see is indistinguishable from data corruption.
|
|
304
|
+
*
|
|
305
|
+
* @param {unknown} value
|
|
306
|
+
* @returns {{ value: unknown, repairs: { path: string, kind: string }[] }}
|
|
307
|
+
*/
|
|
308
|
+
export function losslessJson(value) {
|
|
309
|
+
const repairs = []
|
|
310
|
+
const walk = (input, path) => {
|
|
311
|
+
if (input === undefined) {
|
|
312
|
+
repairs.push({ path, kind: 'undefined' })
|
|
313
|
+
return undefined
|
|
314
|
+
}
|
|
315
|
+
if (typeof input === 'number') {
|
|
316
|
+
if (Object.is(input, -0)) {
|
|
317
|
+
// Silent by design (see above), but still recorded: a flood of these means a
|
|
318
|
+
// producer is doing arithmetic that produces negative zero.
|
|
319
|
+
return 0
|
|
320
|
+
}
|
|
321
|
+
if (!Number.isFinite(input)) {
|
|
322
|
+
repairs.push({ path, kind: Number.isNaN(input) ? 'nan' : 'infinite' })
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
return input
|
|
326
|
+
}
|
|
327
|
+
if (input === null || typeof input !== 'object') return input
|
|
328
|
+
if (Array.isArray(input)) return input.map((entry, index) => walk(entry, `${path}[${index}]`))
|
|
329
|
+
const output = {}
|
|
330
|
+
for (const key of Object.keys(input)) {
|
|
331
|
+
const walked = walk(input[key], path ? `${path}.${key}` : key)
|
|
332
|
+
if (walked !== undefined) output[key] = walked
|
|
333
|
+
}
|
|
334
|
+
return output
|
|
335
|
+
}
|
|
336
|
+
return { value: walk(value, ''), repairs }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Prepare a tool's canonical payload for the harness, and describe what had to change.
|
|
341
|
+
*
|
|
342
|
+
* Every tool's success path goes through this, for the same reason every tool's input
|
|
343
|
+
* goes through {@link definedFields}: the boundary owns what it emits. A tool builds
|
|
344
|
+
* its payload out of numbers it did not compute — Blender's rotations, Python's
|
|
345
|
+
* `-0.0` — and the harness refuses the whole result over one of them.
|
|
346
|
+
*
|
|
347
|
+
* @param {unknown} data
|
|
348
|
+
* @param {(code: string, message: string, detail?: unknown) => object} warning - the
|
|
349
|
+
* caller's warning builder, so this module does not need to import one.
|
|
350
|
+
* @returns {{ data: unknown, warnings: object[] }}
|
|
351
|
+
*/
|
|
352
|
+
export function canonicalData(data, warning) {
|
|
353
|
+
const { value, repairs } = losslessJson(data)
|
|
354
|
+
if (repairs.length === 0) return { data: value, warnings: [] }
|
|
355
|
+
const kinds = [...new Set(repairs.map(repair => repair.kind))]
|
|
356
|
+
return {
|
|
357
|
+
data: value,
|
|
358
|
+
warnings: [
|
|
359
|
+
warning(
|
|
360
|
+
'VALUE_NOT_REPRESENTABLE',
|
|
361
|
+
`${repairs.length} value(s) in this result had no JSON representation and were replaced ` +
|
|
362
|
+
`(${kinds.join(', ')}); the first was at "${repairs[0].path}". This is a defect in whatever ` +
|
|
363
|
+
'produced the value, not in the request that returned it.',
|
|
364
|
+
{ count: repairs.length, kinds, firstPath: repairs[0].path },
|
|
365
|
+
),
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Await a host call and make its payload acceptable to the harness.
|
|
372
|
+
*
|
|
373
|
+
* The one line every tool needs between "the host answered" and "I built a result out
|
|
374
|
+
* of it", so no tool can forget it — the failure it prevents is invisible locally
|
|
375
|
+
* (`JSON.parse(JSON.stringify(x))` normalizes `-0` to `0`) and only appears against the
|
|
376
|
+
* real registry, as an error reported on a successful call.
|
|
377
|
+
*
|
|
378
|
+
* @param {Promise<unknown>} call
|
|
379
|
+
* @param {(code: string, message: string, detail?: unknown) => object} warning
|
|
380
|
+
* @returns {Promise<{ data: any, canonicalWarnings: object[] }>}
|
|
381
|
+
*/
|
|
382
|
+
export async function canonicalCall(call, warning) {
|
|
383
|
+
const { data, warnings } = canonicalData(await call, warning)
|
|
384
|
+
return { data, canonicalWarnings: warnings }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Ask the operator to approve one expensive operation, through the harness's own
|
|
390
|
+
* approval plane.
|
|
391
|
+
*
|
|
392
|
+
* TWO CALLERS, ONE IMPLEMENTATION. `blender_final_render` asks before spending hours
|
|
393
|
+
* of machine time; `blender_asset_ingest` asks before reaching off this machine. Both
|
|
394
|
+
* need the same three things — the composed answerers, an agent identity, an open
|
|
395
|
+
* turn — and both must fail the same way when any of them is missing.
|
|
396
|
+
*
|
|
397
|
+
* WHY THIS IS A SEPARATE FUNCTION, AND WHY IT FAILS CLOSED
|
|
398
|
+
* -------------------------------------------------------
|
|
399
|
+
* `ctx.approval.request` is the harness's own approval plane: it asks every composed
|
|
400
|
+
* answerer (the browser prompt, in a normal deployment), logs the ask and the outcome
|
|
401
|
+
* to the session, and returns one of `'allowed-once' | 'rejected' | 'cancelled' |
|
|
402
|
+
* 'unavailable'`. **`'allowed-once'` is the only grant**, and the service documents
|
|
403
|
+
* its own failure direction: a missing or throwing answerer yields `'unavailable'`.
|
|
404
|
+
*
|
|
405
|
+
* So every path that is not `'allowed-once'` refuses, including "there is no approval
|
|
406
|
+
* service in this deployment at all". That is the right direction for a control whose
|
|
407
|
+
* purpose is to prevent spending hours of machine time: an unanswered question is not
|
|
408
|
+
* a yes. It does mean a headless composition cannot start a 900-frame render without
|
|
409
|
+
* raising the threshold — which is a deliberate trade, and the refusal text says so.
|
|
410
|
+
*
|
|
411
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
412
|
+
* @param {{ agent?: unknown, callId?: unknown, signal?: AbortSignal }} exec
|
|
413
|
+
* @param {{ detail?: { frames?: number, threshold?: number, frameStart?: number|null, frameEnd?: number|null }, message?: string }} refusal
|
|
414
|
+
* @returns {Promise<{ granted: true, outcome: string } | { granted: false, refusal: { text: string, data: object } }>}
|
|
415
|
+
*/
|
|
416
|
+
export async function requestApproval(ctx, exec, ask) {
|
|
417
|
+
// What to ask, and what to say when the answer is not yes, both come from the
|
|
418
|
+
// caller: only it knows whether the cost being approved is machine time or a
|
|
419
|
+
// network fetch.
|
|
420
|
+
const { toolName, reason, refusal, detail = {} } = ask
|
|
421
|
+
const approval = ctx.get('approval')
|
|
422
|
+
const agent = exec?.agent
|
|
423
|
+
|
|
424
|
+
const refuse = (outcome, note) => ({
|
|
425
|
+
granted: false,
|
|
426
|
+
refusal: {
|
|
427
|
+
text: [`Refused: this needs approval, and it was not granted (${outcome}).`, '', note, '', refusal].join('\n'),
|
|
428
|
+
data: { errorCode: ask.refusalCode ?? 'APPROVAL_REFUSED', outcome, ...detail },
|
|
429
|
+
},
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
if (approval === undefined) {
|
|
433
|
+
return refuse('unavailable', 'This deployment composes no approval service, so there was no one to ask.')
|
|
434
|
+
}
|
|
435
|
+
if (agent === undefined) {
|
|
436
|
+
return refuse('unavailable', 'This call carries no agent identity, and an approval must be logged against one.')
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let outcome
|
|
440
|
+
try {
|
|
441
|
+
outcome = await approval.request({
|
|
442
|
+
agent,
|
|
443
|
+
toolName,
|
|
444
|
+
callId: exec?.callId,
|
|
445
|
+
reason,
|
|
446
|
+
signal: exec?.signal,
|
|
447
|
+
})
|
|
448
|
+
} catch (cause) {
|
|
449
|
+
// The service rejects when no turn is open, or when an audit append fails. Both
|
|
450
|
+
// mean the decision could not be recorded, and an unrecorded grant is not a grant.
|
|
451
|
+
return refuse('unavailable', `The approval could not be requested: ${cause?.message ?? String(cause)}`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (outcome !== 'allowed-once') {
|
|
455
|
+
return refuse(String(outcome), 'The operator declined, cancelled, or the prompt was unavailable.')
|
|
456
|
+
}
|
|
457
|
+
return { granted: true, outcome }
|
|
458
|
+
}
|