@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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * M2 model-visible tools: the visual review loop.
3
+ *
4
+ * Plane: Agent preset (SPEC §4.2, §4.3). This module registers tools and publishes
5
+ * NOTHING — it consumes `blenderStudio` from the Host composition.
6
+ *
7
+ * WHO OWNS THE LOOP, AND WHY IT IS SPLIT THIS WAY
8
+ * -----------------------------------------------
9
+ * Three tools, three different jobs, and the split is the design:
10
+ *
11
+ * `blender_preview_views` renders the multi-view plan and MEASURES it. No model
12
+ * call, no repair. This is the cheap observation, and it is what a model uses
13
+ * when it wants to look at something specific.
14
+ *
15
+ * `blender_visual_review` the same, PLUS it asks the vision model what the contact
16
+ * sheet shows. It returns the measured score, the measured issues, and the
17
+ * model's OWN findings side by side, with the sheet attached as an image. This
18
+ * is the tool that makes "the model really sees the preview" checkable: its
19
+ * `reported` findings are the model's words about pixels only it can read, and
20
+ * they are validated against the review they claim to describe.
21
+ *
22
+ * `blender_visual_autofix` the host-owned repair LOOP: propose, apply, re-render,
23
+ * re-score, and adopt only what measures better, bounded by the iteration cap
24
+ * and the repeated-issue rule. It returns a handover package when it stops short
25
+ * of passing.
26
+ *
27
+ * The alternative — one tool that does everything — was rejected because the score
28
+ * has to stay a function of measurements (decision D30). If the repairing loop also
29
+ * wrote the numbers it is judged by, "the score improved" would be a claim about the
30
+ * model rather than a claim about the render.
31
+ *
32
+ * WHICH TOOLS EXIST, AND WHY ONLY THESE
33
+ * -------------------------------------
34
+ * SPEC §11's `blender_final_render`, `blender_export`, `blender_asset_ingest`,
35
+ * `blender_job_status` and `blender_job_cancel` stay absent rather than
36
+ * registered-and-throwing: a tool the model can see is a promise the runtime must
37
+ * keep, and their host services arrive in M3.
38
+ *
39
+ * Owner: DeepBlend Studio — M2
40
+ */
41
+
42
+ import { defineTool } from '@deepseek-ai/dsh-tools'
43
+
44
+ import { BlenderWarningCode, warning } from '@deepblend/dsh-blender-contracts'
45
+
46
+ import {
47
+ TOOL_OUTPUT,
48
+ TOOL_OUTPUT_WITH_IMAGE,
49
+ canonicalCall,
50
+ definedFields,
51
+ describeRevision,
52
+ persistImage,
53
+ renderFailure,
54
+ renderSuccess,
55
+ resolveStudio,
56
+ } from './shared.js'
57
+
58
+ /** A short reminder of what the measured facts mean, reused by both visual tools. */
59
+ const MEASUREMENT_GLOSSARY =
60
+ 'Measured facts per view: mean/p05/p95 display luminance with the clipped-dark and clipped-bright ' +
61
+ 'fractions, and per tracked object its silhouette pixel count (how large it would be with nothing in ' +
62
+ 'the way), its visible pixel count, the visible fraction (occlusion), its frame coverage, its bounding ' +
63
+ 'box and its centroid. These come from the rendered pixels, not from the SceneSpec.'
64
+
65
+ /**
66
+ * Register the M2 visual tools for the calling agent scope.
67
+ *
68
+ * Registration is fiber-scoped: Cordis disposes it when the preset's subtree
69
+ * unmounts, so no manual teardown is needed.
70
+ *
71
+ * @param {import('@deepseek-ai/cordis').Context} ctx
72
+ */
73
+ export function applyVisualTools(ctx) {
74
+ ctx.tools.register(previewViews(ctx))
75
+ ctx.tools.register(visualReview(ctx))
76
+ ctx.tools.register(visualAutofix(ctx))
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // preview_views
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function previewViews(ctx) {
84
+ return defineTool({
85
+ name: 'blender_preview_views',
86
+ description:
87
+ 'Render a MULTI-VIEW preview of a revision in one go: the active camera, a three-quarter angle, a ' +
88
+ 'top-down view and a subject close-up, all from one Blender process, and measure each one. Returns ' +
89
+ 'the images\' paths, a composed contact sheet, and per-view measurements. Rendering does NOT create a ' +
90
+ 'revision — a preview observes a scene rather than changing it — so this is always safe to call and ' +
91
+ 'never needs a baseRevision. Use it to look at a scene; use blender_visual_review when you also want a ' +
92
+ 'score and a judgment.\n\n' + MEASUREMENT_GLOSSARY,
93
+ parameters: {
94
+ projectId: { type: 'string', required: true, description: 'The project id.' },
95
+ revision: { type: 'string', description: 'Revision to render. Defaults to the current one.' },
96
+ roles: {
97
+ type: 'array',
98
+ items: { type: 'string' },
99
+ description:
100
+ 'Which of the standard views to render, in reading order: "active-camera", "three-quarter", ' +
101
+ '"top", "detail". Omit for the project default. A role with no camera assigned to it is skipped.',
102
+ },
103
+ frame: {
104
+ type: 'integer',
105
+ description:
106
+ 'Frame for every view. Defaults to the MIDDLE of the frame range, which for a turntable is far ' +
107
+ 'more informative than the first frame. All views share one frame so they can be compared.',
108
+ },
109
+ width: { type: 'integer', description: 'Override the preview width in pixels.' },
110
+ height: { type: 'integer', description: 'Override the preview height in pixels.' },
111
+ samples: {
112
+ type: 'integer',
113
+ description: 'Override the preview profile\'s sample count; a value above the configured budget is reduced and reported.',
114
+ },
115
+ },
116
+ output: TOOL_OUTPUT,
117
+ async execute(args, exec) {
118
+ const resolved = resolveStudio(ctx)
119
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
120
+ try {
121
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.renderViews({
122
+ ...definedFields({
123
+ projectId: args.projectId,
124
+ revision: args.revision,
125
+ roles: args.roles,
126
+ frame: args.frame,
127
+ width: args.width,
128
+ height: args.height,
129
+ samples: args.samples,
130
+ }),
131
+ signal: exec.signal,
132
+ }), warning)
133
+ const scored = resolved.studio.scoreVisualViews({ views: data.views, subjectId: data.subjectId })
134
+ const notes = [
135
+ `Revision: ${data.revision}`,
136
+ `Subject: ${data.subjectId ?? '(none tagged)'}`,
137
+ `Engine: ${data.profile?.blenderEngine ?? data.profile?.engine}`,
138
+ `Measured score: ${scored.score}/100`,
139
+ '',
140
+ 'Views:',
141
+ ]
142
+ for (const view of data.views ?? []) {
143
+ notes.push(
144
+ ` ${String(view.viewId).padEnd(15)} ${view.path} (camera ${view.cameraId}, frame ${view.frame}, ` +
145
+ `${view.width}x${view.height}) — ${view.purpose ?? ''}`,
146
+ )
147
+ }
148
+ const sheet = (data.revisionPreviews ?? data.contactSheets ?? [])
149
+ notes.push('')
150
+ notes.push('The paths are relative to the project directory. The measured score is what the automated')
151
+ notes.push('scorer makes of these pixels; call blender_visual_review to score AND hear what a vision')
152
+ notes.push('model sees on the contact sheet.')
153
+ return {
154
+ ok: true,
155
+ text: renderSuccess(`Rendered ${data.views.length} view(s) of ${data.revision}.`, { ...data, score: scored.score, issues: scored.issues }, { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }),
156
+ data: { ...data, score: scored.score, issues: scored.issues, sheet },
157
+ }
158
+ } catch (cause) {
159
+ const failure = renderFailure(cause, 'PREVIEW_VIEWS_FAILED')
160
+ return { ok: false, ...failure }
161
+ }
162
+ },
163
+ presentCall: args => ({
164
+ card: 'generic',
165
+ title: `Render ${Array.isArray(args?.roles) ? args.roles.length : 'the standard'} views of "${args?.projectId ?? ''}"`,
166
+ kind: 'other',
167
+ }),
168
+ })
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // visual_review
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function visualReview(ctx) {
176
+ return defineTool({
177
+ name: 'blender_visual_review',
178
+ description:
179
+ 'Render the multi-view plan for a revision, measure it, and SHOW IT TO A VISION MODEL. The contact ' +
180
+ 'sheet is attached to this result, so you see the same image the reviewer saw. Returns three separate ' +
181
+ 'things, and keeping them separate is the point:\n' +
182
+ ' score — computed by the flight recorder from the pixels. Reproducible. Not an opinion.\n' +
183
+ ' issues — the measured problems behind that score, each with the number that caused it.\n' +
184
+ ' reported — what the vision model says it SEES on the sheet, in its own words. The model does not ' +
185
+ 'control the score; its findings are validated against the review (an unknown view or a missing ' +
186
+ 'evidence string is discarded and listed under "rejected").\n' +
187
+ 'If the model proposed a ScenePatch, it comes back as `suggestedOperations` for you to review and ' +
188
+ 'apply with blender_scene_patch. Nothing is committed by this call.',
189
+ parameters: {
190
+ projectId: { type: 'string', required: true, description: 'The project id.' },
191
+ revision: { type: 'string', description: 'Revision to review. Defaults to the current one.' },
192
+ roles: {
193
+ type: 'array',
194
+ items: { type: 'string' },
195
+ description: 'Which standard views to render, in reading order. Omit for the project default.',
196
+ },
197
+ frame: { type: 'integer', description: 'Frame for every view. Defaults to the middle of the frame range.' },
198
+ width: { type: 'integer', description: 'Override the preview width.' },
199
+ height: { type: 'integer', description: 'Override the preview height.' },
200
+ },
201
+ output: TOOL_OUTPUT_WITH_IMAGE,
202
+ async execute(args, exec) {
203
+ const resolved = resolveStudio(ctx)
204
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable, image: null }
205
+ try {
206
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.visualReview({
207
+ ...definedFields({
208
+ projectId: args.projectId,
209
+ revision: args.revision,
210
+ roles: args.roles,
211
+ frame: args.frame,
212
+ width: args.width,
213
+ height: args.height,
214
+ }),
215
+ consultReviewer: true,
216
+ signal: exec.signal,
217
+ }), warning)
218
+
219
+ const sheetPath = data.sheetArtifact?.path
220
+ const bytes = await resolved.studio.readSheetPng(args.projectId, data)
221
+ const persisted = await persistImage(resolved.attachments, bytes, `contact-sheet-${data.revision}.png`)
222
+
223
+ const notes = describeReviewNotes(data)
224
+ notes.push('')
225
+ notes.push(MEASUREMENT_GLOSSARY)
226
+ notes.push(
227
+ `Contact sheet: ${sheetPath ?? '(not recorded)'}` +
228
+ `${persisted.image !== null ? ' — also attached to this result as an image.' : '.'}`,
229
+ )
230
+ if (persisted.note !== null) notes.push(persisted.note)
231
+
232
+ return {
233
+ ok: true,
234
+ text: renderSuccess(`Visual review of ${data.revision}: ${data.score}/100.`, data, { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }),
235
+ data,
236
+ image: persisted.image,
237
+ }
238
+ } catch (cause) {
239
+ const failure = renderFailure(cause, 'VISUAL_REVIEW_FAILED')
240
+ return { ok: false, ...failure, image: null }
241
+ }
242
+ },
243
+ presentCall: args => ({ card: 'generic', title: `Review "${args?.projectId ?? ''}" visually`, kind: 'read' }),
244
+ })
245
+ }
246
+
247
+ /**
248
+ * The two lines that describe ONE measured issue: the finding, then the number behind it.
249
+ *
250
+ * Extracted (round 40) because this pair was written out TWICE — in the review's issue list and in the
251
+ * open-issue list of `blender_visual_autofix` — and a format with two copies drifts silently: the day
252
+ * one gains a clause, the two tools describe the same measurement differently and nothing fails. The
253
+ * mutation run that found it is recorded in `docs/architecture-decisions.md` (D126).
254
+ *
255
+ * @param {{severity: string, code: string, viewId: string, objectId?: string|null, evidence: string}} issue
256
+ * @returns {string[]} the two lines, in reading order
257
+ */
258
+ export function describeIssueLines(issue) {
259
+ return [
260
+ ` [${issue.severity}] ${issue.code} in view "${issue.viewId}"${issue.objectId ? ` on "${issue.objectId}"` : ''}`,
261
+ ` ${issue.evidence}`,
262
+ ]
263
+ }
264
+
265
+ /**
266
+ * The block a model reads after a review: the measurement, the measured issues, and what the vision
267
+ * model claimed — with the claims that were DISCARDED named as such.
268
+ *
269
+ * Exported and pure (round 40) for the same reason `describeJobLines` is: this is the text a model
270
+ * acts on, and the coverage reading showed every branch that needs a review WITH findings to be
271
+ * dark, because no suite had ever composed one. A rule reachable only through a real render PLUS a
272
+ * real model call is a rule nobody has checked.
273
+ *
274
+ * @param {object} data - the canonical review result
275
+ * @returns {string[]} the note lines, in reading order
276
+ */
277
+ export function describeReviewNotes(data) {
278
+ const reviewerError = data.reviewer?.error
279
+ const reviewerUnavailable = reviewerError !== null && reviewerError !== undefined
280
+ const notes = [
281
+ `Revision: ${data.revision} (rendered from ${data.checkpointRevision})`,
282
+ `Score: ${data.score}/100 — ${data.pass ? 'PASSES' : 'does NOT pass'} the delivery threshold`,
283
+ `Subject: ${data.subjectId ?? '(none tagged)'}`,
284
+ `Parts: ${(data.parts ?? []).length > 0 ? data.parts.join(', ') + ' (declared subject-part: they ARE the subject, so they cannot be in its way)' : '(none declared; only the subject is judged for occlusion)'}`,
285
+ '',
286
+ 'Measured issues:',
287
+ ]
288
+ if (data.issues.length === 0) {
289
+ notes.push(' (none)')
290
+ } else {
291
+ for (const issue of data.issues) notes.push(...describeIssueLines(issue))
292
+ }
293
+ notes.push('')
294
+ if (reviewerUnavailable) {
295
+ notes.push('The vision reviewer could NOT be consulted, so this result has the measurements and the')
296
+ notes.push(`sheet but no second opinion: [${reviewerError.code}] ${reviewerError.message}`)
297
+ notes.push('')
298
+ }
299
+ notes.push('What the vision model reported seeing on the sheet:')
300
+ if ((data.reported ?? []).length === 0) {
301
+ notes.push(reviewerUnavailable ? ' (no reviewer was available)' : ' (nothing that survived validation)')
302
+ } else {
303
+ for (const finding of data.reported) {
304
+ notes.push(
305
+ ` [${finding.severity}] ${finding.category} in view "${finding.viewId}"` +
306
+ `${finding.objectId ? ` on "${finding.objectId}"` : ''} (confidence ${finding.confidence})`,
307
+ )
308
+ notes.push(` ${finding.evidence}`)
309
+ }
310
+ }
311
+ if ((data.rejected ?? []).length > 0) {
312
+ notes.push('')
313
+ notes.push(`Discarded findings (${data.rejected.length}) — they named something the review does not contain:`)
314
+ for (const entry of data.rejected) notes.push(` - ${entry.reason}`)
315
+ }
316
+ if ((data.suggestedOperations ?? []).length > 0) {
317
+ notes.push('')
318
+ notes.push(`The reviewer proposed ${data.suggestedOperations.length} ScenePatch operation(s); apply them with`)
319
+ notes.push('blender_scene_patch if you agree, or run blender_visual_autofix to let the host try them.')
320
+ }
321
+ return notes
322
+ }
323
+
324
+ /**
325
+ * The block a model reads after `blender_visual_autofix`: what each round did, what is still open,
326
+ * and — when the loop stopped short — the handover it must work from.
327
+ *
328
+ * Extracted and exported (round 40) for the same reason as `describeReviewNotes`: it is the text a
329
+ * model acts on, it has six branches (a round with and without a new revision, a round with and
330
+ * without a reason, rounds that saw findings, open issues, and a handover with and without tried
331
+ * revisions), and before this extraction no suite could reach one of them without a real repair loop,
332
+ * a real render per round, and a real model call per round.
333
+ *
334
+ * @param {object} data - the canonical loop result
335
+ * @returns {string[]} the note lines, in reading order
336
+ */
337
+ export function describeLoopNotes(data) {
338
+ const notes = [
339
+ `Score: ${data.startScore} -> ${data.finalScore} (${data.passed ? 'PASSES' : 'still below the threshold'})`,
340
+ `Revision: ${data.startRevision} -> ${data.finalRevision}`,
341
+ `Rounds: ${data.iterations} of ${data.maxIterations} used`,
342
+ `Stopped: ${data.stopReason}`,
343
+ '',
344
+ 'Round log:',
345
+ ]
346
+ for (const round of data.rounds) {
347
+ const detail = round.newRevision !== null ? ` -> ${round.newRevision}` : ''
348
+ notes.push(
349
+ ` round ${round.round}: ${round.outcome}${detail} — score ${round.score}` +
350
+ `${round.reason !== null ? `, ${round.reason}` : ''}`,
351
+ )
352
+ for (const finding of round.reported ?? []) {
353
+ notes.push(` saw: [${finding.category}] ${finding.evidence}`)
354
+ }
355
+ }
356
+ if ((data.openIssues ?? []).length > 0) {
357
+ notes.push('')
358
+ notes.push('Still open:')
359
+ for (const issue of data.openIssues) notes.push(...describeIssueLines(issue))
360
+ }
361
+ if (data.handover !== null) {
362
+ notes.push('')
363
+ notes.push('HUMAN/SESSION HANDOVER — the loop stopped short of passing:')
364
+ notes.push(` reason: ${data.handover.reason}`)
365
+ notes.push(` work from: ${data.handover.revision}`)
366
+ if ((data.handover.attemptedRevisions ?? []).length > 0) {
367
+ notes.push(` tried: ${data.handover.attemptedRevisions.join(', ')} (kept in the history, not adopted)`)
368
+ }
369
+ notes.push(' next steps:')
370
+ for (const suggestion of data.handover.suggestions) notes.push(` - ${suggestion}`)
371
+ }
372
+ return notes
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // visual_autofix
377
+ // ---------------------------------------------------------------------------
378
+
379
+ function visualAutofix(ctx) {
380
+ return defineTool({
381
+ name: 'blender_visual_autofix',
382
+ description:
383
+ 'Run the automated visual repair loop on a revision: review, propose a ScenePatch, commit it, render ' +
384
+ 'and measure again, and KEEP the change only if the score actually went up. Stops when the score ' +
385
+ `passes, when the iteration cap is reached, or when the same finding recurs without improving. ` +
386
+ 'Because it stops on its own, a handover package comes back whenever it stopped short of passing: the ' +
387
+ 'revision to work from (never a polluted one), the open issues with their measurements, and concrete ' +
388
+ 'next steps.\n\n' +
389
+ 'A round that does not improve the score is rolled back — the revision stays in the history, but the ' +
390
+ 'project does not move to a worse one. Every round is reported, including the ones that failed, so you ' +
391
+ 'can see what was already tried before proposing another change yourself.\n\n' +
392
+ MEASUREMENT_GLOSSARY,
393
+ parameters: {
394
+ projectId: { type: 'string', required: true, description: 'The project id.' },
395
+ revision: { type: 'string', description: 'Revision to start from. Defaults to the current one.' },
396
+ maxIterations: {
397
+ type: 'integer',
398
+ description: 'Cap on repair rounds. Defaults to the configured maximum (5, per SPEC).',
399
+ },
400
+ minConfidenceForAutoFix: {
401
+ type: 'number',
402
+ description:
403
+ 'Findings below this confidence are reported but never auto-applied. Defaults to the configured ' +
404
+ 'value (0.8). Raise it to make the loop more conservative.',
405
+ },
406
+ },
407
+ output: TOOL_OUTPUT,
408
+ async execute(args, exec) {
409
+ const resolved = resolveStudio(ctx)
410
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
411
+ try {
412
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.visualLoop({
413
+ ...definedFields({
414
+ projectId: args.projectId,
415
+ revision: args.revision,
416
+ maxIterations: args.maxIterations,
417
+ minConfidenceForAutoFix: args.minConfidenceForAutoFix,
418
+ }),
419
+ signal: exec.signal,
420
+ }), warning)
421
+ const notes = describeLoopNotes(data)
422
+ return {
423
+ ok: true,
424
+ text: renderSuccess(
425
+ data.passed
426
+ ? `Visual repair reached ${data.finalScore}/100 on ${data.finalRevision}.`
427
+ : `Visual repair stopped at ${data.finalScore}/100 on ${data.finalRevision}; it needs a human decision.`,
428
+ data,
429
+ { notes, warnings: canonicalWarnings },
430
+ ),
431
+ data,
432
+ }
433
+ } catch (cause) {
434
+ const failure = renderFailure(cause, 'VISUAL_AUTOFIX_FAILED')
435
+ return { ok: false, ...failure }
436
+ }
437
+ },
438
+ presentCall: args => ({
439
+ card: 'generic',
440
+ title: `Auto-fix visuals of "${args?.projectId ?? ''}"`,
441
+ kind: 'edit',
442
+ }),
443
+ })
444
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@deepblend/dsh-blender-tool",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Agent-preset tool consumer: registers the model-visible DeepBlend Blender tools. Publishes no service.",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./package.json": "./package.json"
12
+ },
13
+ "dependencies": {
14
+ "@deepblend/dsh-blender-contracts": "0.1.0"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/pearjelly/deep-blend.git"
20
+ },
21
+ "peerDependencies": {
22
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0"
23
+ }
24
+ }