@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/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
+ }