@goodandready/dsh-image-gen 0.11.21 → 0.11.23

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.
@@ -1,496 +1,505 @@
1
- import { registerGenerationPackTool } from './generation-pack.js'
2
- import { defineTool } from '@deepseek-ai/dsh-tools'
3
- import path from 'node:path'
4
- import { mkdir, writeFile } from 'node:fs/promises'
5
- import {
6
- computeGenerationHash,
7
- IMAGE_SIZES,
8
- OUTPUT_FORMATS,
9
- PROVIDER_KEYS,
10
- makeProviders,
11
- normalizeCount,
12
- toLosslessJson,
13
- saveAttachmentSafe,
14
- } from '../providers.js'
15
- import { buildDualOutputMarkdown } from '../resolve-image.js'
16
- import { executeWithQualityGate } from '../quality-gate.js'
17
- import { calculateGenerationCost, assertBudgetAvailable } from '../cost-meter.js'
18
- import { trackAndAssertLoopGuard } from '../loop-guard.js'
19
- import { getCachedGeneration } from '../generation-cache.js'
20
- import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
21
- import {
22
- readHistory,
23
- findCachedGeneration,
24
- findCached,
25
- findCachedByPrompt,
26
- cachedResult,
27
- } from '../history.js'
28
- import {
29
- asyncPool,
30
- validateGenerationParams,
31
- prepareGenerationPrompt,
32
- saveGeneratedImageArtifact,
33
- } from '../generation-helpers.js'
34
-
35
- // generation — image-gen tools (generate_image, generate_image_pack). Extracted from apply() (#216).
36
-
37
- export function registerGenerationTools(ctx, deps, options = {}) {
38
- const {
39
- config,
40
- live,
41
- resolveSource,
42
- slugify,
43
- resolveApiKey,
44
- } = deps
45
- ctx.effect(() => {
46
- ctx.tools.register(
47
- defineTool({
48
- name: 'generate_image',
49
- description: 'Generate an image from prompt. Saves the image to workspace and conversation. Pass count (1-4) for variations.',
50
- parameters: {
51
- prompt: {
52
- type: 'string',
53
- required: true,
54
- description: 'Detailed description of the image to generate.',
55
- },
56
- image_size: {
57
- type: 'string',
58
- description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config?.defaultSize || 'square_hd'}.`,
59
- },
60
- aspect_ratio: {
61
- type: 'string',
62
- enum: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
63
- description: 'Optional aspect ratio (e.g. 16:9, 1:1), overrides image_size.',
64
- },
65
- seed: {
66
- type: 'integer',
67
- description: 'Optional seed for reproducible output.',
68
- },
69
- output_format: {
70
- type: 'string',
71
- enum: OUTPUT_FORMATS,
72
- description: `Output format. Default: ${config?.defaultFormat || 'png'}.`,
73
- },
74
- output_name: {
75
- type: 'string',
76
- description: 'Optional file name stem for the saved image.',
77
- },
78
- output_dir: {
79
- type: 'string',
80
- description: 'Optional custom directory to write images into.',
81
- },
82
- style_preset: {
83
- type: 'string',
84
- description: 'Optional style preset: cinematic, photorealistic, anime, minimalist_vector, isometric_3d, analog_film, cyberpunk, claymation.',
85
- },
86
- palette_colors: {
87
- type: 'array',
88
- items: { type: 'string' },
89
- description: 'Optional hex color codes (#1e1b4b) to harmonize palette.',
90
- },
91
- count: {
92
- type: 'integer',
93
- description: 'Variations count (1-4, default 1).',
94
- },
95
- prompts: {
96
- type: 'array',
97
- items: {
98
- oneOf: [
99
- { type: 'string' },
100
- { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
101
- ],
102
- },
103
- description: 'Optional list of prompts for batch generation.',
104
- },
105
- negative_prompt: {
106
- type: 'string',
107
- description: 'Optional text describing what NOT to draw.',
108
- },
109
- guidance_scale: {
110
- type: 'number',
111
- description: 'Optional prompt adherence (1-20).',
112
- },
113
- quality: {
114
- type: 'string',
115
- enum: ['auto', 'low', 'medium', 'high'],
116
- description: 'Optional quality for supported providers.',
117
- },
118
- style: {
119
- type: 'string',
120
- description: 'Optional style preset for supported providers.',
121
- },
122
- source_image: {
123
- type: 'string',
124
- description: 'Optional source image path or attachment ID for img2img.',
125
- },
126
- mask: {
127
- type: 'string',
128
- description: 'Optional inpaint mask path or attachment ID.',
129
- },
130
- strength: {
131
- type: 'number',
132
- description: 'Optional edit strength (0-1).',
133
- },
134
- quality_gate: {
135
- type: 'boolean',
136
- description: 'Optional automated quality gate override.',
137
- },
138
- force: {
139
- type: 'boolean',
140
- description: 'Bypass cache and force new generation.',
141
- },
142
- reference_image: {
143
- type: 'string',
144
- description: 'Optional reference image path or URL for consistency.',
145
- },
146
- reference_strength: {
147
- type: 'number',
148
- description: 'Strength of reference influence (0.1-1.0).',
149
- },
150
- face_reference: {
151
- type: 'string',
152
- description: 'Portrait reference image for facial identity preservation.',
153
- },
154
- face_strength: {
155
- type: 'number',
156
- description: 'Facial identity similarity weight (0.1-1.0).',
157
- },
158
- },
159
- output: {
160
- schema: {
161
- type: 'object',
162
- additionalProperties: true,
163
- properties: {
164
- summary: { type: 'string' },
165
- channel: { type: 'string' },
166
- provider: { type: 'string' },
167
- model: { type: 'string' },
168
- _fallback: {
169
- type: 'object',
170
- additionalProperties: true,
171
- properties: {
172
- triggered: { type: 'boolean' },
173
- primaryProvider: { type: 'string' },
174
- providerUsed: { type: 'string' },
175
- attempts: {
176
- type: 'array',
177
- items: {
178
- type: 'object',
179
- additionalProperties: true,
180
- properties: {
181
- provider: { type: 'string' },
182
- durationMs: { type: 'number' },
183
- success: { type: 'boolean' },
184
- error: { type: 'string' },
185
- },
186
- },
187
- },
188
- },
189
- },
190
- path: { type: 'string' },
191
- url: { type: 'string' },
192
- width: { type: 'integer' },
193
- height: { type: 'integer' },
194
- seed: { type: 'integer' },
195
- prompt: { type: 'string' },
196
- originalPrompt: { type: 'string' },
197
- format: { type: 'string' },
198
- cost: { type: 'number' },
199
- fromCache: { type: 'boolean' },
200
- qualityReport: { type: 'object', additionalProperties: true },
201
- anchor: {
202
- type: 'object',
203
- additionalProperties: true,
204
- properties: {
205
- label: { type: 'string' },
206
- mode: { type: 'string' },
207
- },
208
- },
209
- attachment: {
210
- type: 'object',
211
- additionalProperties: true,
212
- properties: {
213
- attachmentId: { type: 'string' },
214
- mediaType: { type: 'string' },
215
- bytes: { type: 'integer' },
216
- width: { type: 'integer' },
217
- height: { type: 'integer' },
218
- name: { type: 'string' },
219
- },
220
- },
221
- images: {
222
- type: 'array',
223
- items: {
224
- type: 'object',
225
- additionalProperties: true,
226
- properties: {
227
- path: { type: 'string' },
228
- url: { type: 'string' },
229
- width: { type: 'integer' },
230
- height: { type: 'integer' },
231
- seed: { type: 'integer' },
232
- prompt: { type: 'string' },
233
- originalPrompt: { type: 'string' },
234
- format: { type: 'string' },
235
- cost: { type: 'number' },
236
- fromCache: { type: 'boolean' },
237
- qualityReport: { type: 'object', additionalProperties: true },
238
- _fallback: {
239
- type: 'object',
240
- additionalProperties: true,
241
- properties: {
242
- triggered: { type: 'boolean' },
243
- primaryProvider: { type: 'string' },
244
- providerUsed: { type: 'string' },
245
- attempts: {
246
- type: 'array',
247
- items: {
248
- type: 'object',
249
- additionalProperties: true,
250
- properties: {
251
- provider: { type: 'string' },
252
- durationMs: { type: 'number' },
253
- success: { type: 'boolean' },
254
- error: { type: 'string' },
255
- },
256
- },
257
- },
258
- },
259
- },
260
- provider: { type: 'string' },
261
- model: { type: 'string' },
262
- attachment: {
263
- type: 'object',
264
- additionalProperties: true,
265
- properties: {
266
- attachmentId: { type: 'string' },
267
- mediaType: { type: 'string' },
268
- bytes: { type: 'integer' },
269
- width: { type: 'integer' },
270
- height: { type: 'integer' },
271
- name: { type: 'string' },
272
- },
273
- },
274
- },
275
- },
276
- },
277
- },
278
- },
279
- render(args, value) {
280
- const list = value.images || [value]
281
- const lines = list.map((img) => `Generated image (${img.width}×${img.height}, ${img.format}, seed ${img.seed}) via ${value.provider || 'image-gen'}/${value.model || 'default'}: ${img.path}`)
282
- const summary = lines.join('\n')
283
- if (live().deliverAs !== 'image') {
284
- const urls = list.map((img) => img.url).filter(Boolean)
285
- return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
286
- }
287
- const blocks = [{ type: 'text', text: summary }]
288
- for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
289
- return blocks
290
- },
291
- },
292
- isConcurrencySafe: () => false,
293
- timeoutMs: (config?.timeoutMs || 180000) + 30000,
294
- async execute(args, exec) {
295
- const cfg = live()
296
-
297
- // #168: Fail-fast loop guard protecting against runaway agent loops
298
- const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
299
- if (cfg.loopGuardLimit > 0) {
300
- trackAndAssertLoopGuard(sessionId, {
301
- limit: cfg.loopGuardLimit,
302
- prompt: args.prompt,
303
- })
304
- }
305
-
306
- const { provider, deliverAs, size, aspectPixels, format } = validateGenerationParams(cfg, args)
307
-
308
- // Subscriptions service is optional: without it, subscription providers
309
- // gracefully decline while other providers continue functioning.
310
- let subscriptionImages
311
- try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
312
-
313
- const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
314
- const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
315
-
316
- const {
317
- enhanced,
318
- effectivePrompt,
319
- effectiveNegative,
320
- effectiveGuidance,
321
- activeAnchor,
322
- resolvedFaceRef,
323
- } = await prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource })
324
-
325
- // #167: Assert daily budget availability before calling API
326
- const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
327
- const estimatedCost = calculateGenerationCost({
328
- provider,
329
- model: cfg.model || cfg.customModel,
330
- size,
331
- count: totalCount,
332
- })
333
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
334
- const providers = makeProviders(
335
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
336
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
337
- )
338
- // Subscription providers return {ok:false, reason} instead of throwing:
339
- // rejection reaches the model as text. Without this guard,
340
- // execution would proceed with empty bytes.
341
- const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
342
- const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
343
- const gen = await providers[providerKey](jobSeed, promptArg)
344
- guard(gen)
345
- return await saveGeneratedImageArtifact({
346
- ctx,
347
- exec,
348
- cfg,
349
- args,
350
- gen,
351
- promptArg,
352
- jobSeed,
353
- provider,
354
- deliverAs,
355
- size,
356
- format,
357
- effectiveNegative,
358
- effectiveGuidance,
359
- enhanced,
360
- slugify,
361
- estimatedCost,
362
- totalCount,
363
- })
364
- }
365
-
366
- const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
367
- // Fallback cascade: evaluate configured provider, falling back to alternates
368
- // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
369
- const order = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
370
- const qgEnabled = args.quality_gate ?? cfg.qualityGate
371
- const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
372
- k,
373
- async (s, p) => {
374
- const { generated, qualityReport } = await executeWithQualityGate(
375
- (seedVal) => one(seedVal, k, p),
376
- {
377
- initialSeed: s,
378
- enabled: qgEnabled,
379
- ctx,
380
- prompt: p,
381
- }
382
- )
383
- return { ...generated, qualityReport }
384
- }
385
- ]))
386
- const images = []
387
- const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
388
- const checkCache = async (seedVal, promptVal) => {
389
- const h = computeGenerationHash({
390
- provider,
391
- model: cfg.model || cfg.customModel,
392
- prompt: promptVal,
393
- seed: seedVal,
394
- size: args.image_size || cfg.defaultSize,
395
- style: args.style || cfg.stylePreset,
396
- })
397
-
398
- // #170: Content-addressed disk cache check (<50ms return)
399
- if (cfg.diskCache !== false && !args.force) {
400
- const diskHit = getCachedGeneration(h, { force: args.force })
401
- if (diskHit) {
402
- const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
403
- const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
404
- const name = `${stem}.${extension}`
405
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
406
- const sessionCwd = exec.agent?.session?.header?.cwd
407
- const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
408
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
409
- await mkdir(outDir, { recursive: true })
410
- const filePath = path.join(outDir, name)
411
- await writeFile(filePath, diskHit.bytes)
412
-
413
- return {
414
- path: filePath,
415
- url: localUrl,
416
- width: diskHit.width || attachment.width,
417
- height: diskHit.height || attachment.height,
418
- seed: seedVal,
419
- prompt: promptVal,
420
- cost: 0,
421
- fromCache: true,
422
- format: diskHit.mediaType.replace('image/', ''),
423
- attachment: {
424
- attachmentId: attachment.attachmentId,
425
- mediaType: attachment.mediaType,
426
- bytes: attachment.bytes,
427
- width: attachment.width,
428
- height: attachment.height,
429
- name: attachment.name,
430
- },
431
- }
432
- }
433
- }
434
-
435
- if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
436
- const hPrompt = computeGenerationHash({
437
- provider,
438
- model: cfg.model,
439
- prompt: promptVal,
440
- seed: seedVal,
441
- size: args.image_size || cfg.defaultSize,
442
- style: args.style || cfg.stylePreset,
443
- })
444
- const found = findCachedGeneration(historyEntries, hPrompt)
445
- if (found) return await cachedResult(found)
446
- if (cfg.cacheBySeed) {
447
- const bySeed = await findCached(historyEntries, seedVal, promptVal)
448
- if (bySeed) return await cachedResult(bySeed)
449
- }
450
- if (cfg.cacheByPrompt) {
451
- const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
452
- if (byPrompt) return await cachedResult(byPrompt)
453
- }
454
- return undefined
455
- }
456
-
457
- const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
458
- if (batchPrompts) {
459
- const tasks = batchPrompts.map((item, i) => async () => {
460
- const text = typeof item === 'string' ? item : (item && item.text) || ''
461
- const cached = await checkCache(seedBase + i, text)
462
- return cached || await executeWithFallback(generators, order, seedBase + i, text, { logger: ctx.logger })
463
- })
464
- const parallelResults = await asyncPool(tasks, 3)
465
- images.push(...parallelResults)
466
- } else {
467
- const count = normalizeCount(args.count)
468
- const tasks = Array.from({ length: count }, (_, i) => async () => {
469
- const cached = await checkCache(seedBase + i, effectivePrompt)
470
- return cached || await executeWithFallback(generators, order, seedBase + i, effectivePrompt, { logger: ctx.logger })
471
- })
472
- const parallelResults = await asyncPool(tasks, 3)
473
- images.push(...parallelResults)
474
- }
475
- const first = images[0]
476
- const dualOutputSummary = buildDualOutputMarkdown({
477
- action: 'generated',
478
- filePath: first.path,
479
- width: first.width,
480
- height: first.height,
481
- mediaType: first.mediaType || ('image/' + (first.format || 'png')),
482
- seed: first.seed,
483
- provider,
484
- model: cfg.model || cfg.customModel || 'default',
485
- cost: first.cost,
486
- attachmentId: first.attachment?.attachmentId || 'N/A',
487
- })
488
- return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
489
- },
490
- }),
491
- )
492
- }, 'dsh-image-gen: tool generate_image')
493
- if (!options.coreOnly) {
494
- registerGenerationPackTool(ctx, deps)
495
- }
496
- }
1
+ import { registerGenerationPackTool } from './generation-pack.js'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import path from 'node:path'
4
+ import { mkdir, writeFile } from 'node:fs/promises'
5
+ import {
6
+ computeGenerationHash,
7
+ IMAGE_SIZES,
8
+ OUTPUT_FORMATS,
9
+ PROVIDER_KEYS,
10
+ makeProviders,
11
+ normalizeCount,
12
+ toLosslessJson,
13
+ saveAttachmentSafe,
14
+ } from '../providers.js'
15
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
16
+ import { executeWithQualityGate } from '../quality-gate.js'
17
+ import { calculateGenerationCost, assertBudgetAvailable } from '../cost-meter.js'
18
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
19
+ import { getCachedGeneration } from '../generation-cache.js'
20
+ import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
21
+ import {
22
+ readHistory,
23
+ findCachedGeneration,
24
+ findCached,
25
+ findCachedByPrompt,
26
+ cachedResult,
27
+ } from '../history.js'
28
+ import {
29
+ asyncPool,
30
+ validateGenerationParams,
31
+ prepareGenerationPrompt,
32
+ saveGeneratedImageArtifact,
33
+ } from '../generation-helpers.js'
34
+
35
+ // generation — image-gen tools (generate_image, generate_image_pack). Extracted from apply() (#216).
36
+
37
+ export function registerGenerationTools(ctx, deps, options = {}) {
38
+ const {
39
+ config,
40
+ live,
41
+ resolveSource,
42
+ slugify,
43
+ resolveApiKey,
44
+ } = deps
45
+ ctx.effect(() => {
46
+ ctx.tools.register(
47
+ defineTool({
48
+ name: 'generate_image',
49
+ description: 'Generate an image from prompt. Saves the image to workspace and conversation. Pass count (1-4) for variations.',
50
+ parameters: {
51
+ prompt: {
52
+ type: 'string',
53
+ required: true,
54
+ description: 'Detailed description of the image to generate.',
55
+ },
56
+ image_size: {
57
+ type: 'string',
58
+ description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config?.defaultSize || 'square_hd'}.`,
59
+ },
60
+ aspect_ratio: {
61
+ type: 'string',
62
+ enum: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
63
+ description: 'Optional aspect ratio (e.g. 16:9, 1:1), overrides image_size.',
64
+ },
65
+ seed: {
66
+ type: 'integer',
67
+ description: 'Optional seed for reproducible output.',
68
+ },
69
+ output_format: {
70
+ type: 'string',
71
+ enum: OUTPUT_FORMATS,
72
+ description: `Output format. Default: ${config?.defaultFormat || 'png'}.`,
73
+ },
74
+ output_name: {
75
+ type: 'string',
76
+ description: 'Optional file name stem for the saved image.',
77
+ },
78
+ output_dir: {
79
+ type: 'string',
80
+ description: 'Optional custom directory to write images into.',
81
+ },
82
+ style_preset: {
83
+ type: 'string',
84
+ description: 'Optional style preset: cinematic, photorealistic, anime, minimalist_vector, isometric_3d, analog_film, cyberpunk, claymation.',
85
+ },
86
+ palette_colors: {
87
+ type: 'array',
88
+ items: { type: 'string' },
89
+ description: 'Optional hex color codes (#1e1b4b) to harmonize palette.',
90
+ },
91
+ count: {
92
+ type: 'integer',
93
+ description: 'Variations count (1-4, default 1).',
94
+ },
95
+ prompts: {
96
+ type: 'array',
97
+ items: {
98
+ oneOf: [
99
+ { type: 'string' },
100
+ { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
101
+ ],
102
+ },
103
+ description: 'Optional list of prompts for batch generation.',
104
+ },
105
+ negative_prompt: {
106
+ type: 'string',
107
+ description: 'Optional text describing what NOT to draw.',
108
+ },
109
+ guidance_scale: {
110
+ type: 'number',
111
+ description: 'Optional prompt adherence (1-20).',
112
+ },
113
+ quality: {
114
+ type: 'string',
115
+ enum: ['auto', 'low', 'medium', 'high'],
116
+ description: 'Optional quality for supported providers.',
117
+ },
118
+ style: {
119
+ type: 'string',
120
+ description: 'Optional style preset for supported providers.',
121
+ },
122
+ source_image: {
123
+ type: 'string',
124
+ description: 'Optional source image path or attachment ID for img2img.',
125
+ },
126
+ mask: {
127
+ type: 'string',
128
+ description: 'Optional inpaint mask path or attachment ID.',
129
+ },
130
+ strength: {
131
+ type: 'number',
132
+ description: 'Optional edit strength (0-1).',
133
+ },
134
+ quality_gate: {
135
+ type: 'boolean',
136
+ description: 'Optional automated quality gate override.',
137
+ },
138
+ force: {
139
+ type: 'boolean',
140
+ description: 'Bypass cache and force new generation.',
141
+ },
142
+ reference_image: {
143
+ type: 'string',
144
+ description: 'Optional reference image path or URL for consistency.',
145
+ },
146
+ reference_strength: {
147
+ type: 'number',
148
+ description: 'Strength of reference influence (0.1-1.0).',
149
+ },
150
+ face_reference: {
151
+ type: 'string',
152
+ description: 'Portrait reference image for facial identity preservation.',
153
+ },
154
+ face_strength: {
155
+ type: 'number',
156
+ description: 'Facial identity similarity weight (0.1-1.0).',
157
+ },
158
+ },
159
+ output: {
160
+ schema: {
161
+ type: 'object',
162
+ additionalProperties: true,
163
+ properties: {
164
+ summary: { type: 'string' },
165
+ channel: { type: 'string' },
166
+ provider: { type: 'string' },
167
+ model: { type: 'string' },
168
+ _fallback: {
169
+ type: 'object',
170
+ additionalProperties: true,
171
+ properties: {
172
+ triggered: { type: 'boolean' },
173
+ primaryProvider: { type: 'string' },
174
+ providerUsed: { type: 'string' },
175
+ attempts: {
176
+ type: 'array',
177
+ items: {
178
+ type: 'object',
179
+ additionalProperties: true,
180
+ properties: {
181
+ provider: { type: 'string' },
182
+ durationMs: { type: 'number' },
183
+ success: { type: 'boolean' },
184
+ error: { type: 'string' },
185
+ },
186
+ },
187
+ },
188
+ },
189
+ },
190
+ path: { type: 'string' },
191
+ url: { type: 'string' },
192
+ width: { type: 'integer' },
193
+ height: { type: 'integer' },
194
+ seed: { type: 'integer' },
195
+ prompt: { type: 'string' },
196
+ originalPrompt: { type: 'string' },
197
+ format: { type: 'string' },
198
+ cost: { type: 'number' },
199
+ fromCache: { type: 'boolean' },
200
+ qualityReport: { type: 'object', additionalProperties: true },
201
+ anchor: {
202
+ type: 'object',
203
+ additionalProperties: true,
204
+ properties: {
205
+ label: { type: 'string' },
206
+ mode: { type: 'string' },
207
+ },
208
+ },
209
+ attachment: {
210
+ type: 'object',
211
+ additionalProperties: true,
212
+ properties: {
213
+ attachmentId: { type: 'string' },
214
+ mediaType: { type: 'string' },
215
+ bytes: { type: 'integer' },
216
+ width: { type: 'integer' },
217
+ height: { type: 'integer' },
218
+ name: { type: 'string' },
219
+ },
220
+ },
221
+ images: {
222
+ type: 'array',
223
+ items: {
224
+ type: 'object',
225
+ additionalProperties: true,
226
+ properties: {
227
+ path: { type: 'string' },
228
+ url: { type: 'string' },
229
+ width: { type: 'integer' },
230
+ height: { type: 'integer' },
231
+ seed: { type: 'integer' },
232
+ prompt: { type: 'string' },
233
+ originalPrompt: { type: 'string' },
234
+ format: { type: 'string' },
235
+ cost: { type: 'number' },
236
+ fromCache: { type: 'boolean' },
237
+ qualityReport: { type: 'object', additionalProperties: true },
238
+ _fallback: {
239
+ type: 'object',
240
+ additionalProperties: true,
241
+ properties: {
242
+ triggered: { type: 'boolean' },
243
+ primaryProvider: { type: 'string' },
244
+ providerUsed: { type: 'string' },
245
+ attempts: {
246
+ type: 'array',
247
+ items: {
248
+ type: 'object',
249
+ additionalProperties: true,
250
+ properties: {
251
+ provider: { type: 'string' },
252
+ durationMs: { type: 'number' },
253
+ success: { type: 'boolean' },
254
+ error: { type: 'string' },
255
+ },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ provider: { type: 'string' },
261
+ model: { type: 'string' },
262
+ attachment: {
263
+ type: 'object',
264
+ additionalProperties: true,
265
+ properties: {
266
+ attachmentId: { type: 'string' },
267
+ mediaType: { type: 'string' },
268
+ bytes: { type: 'integer' },
269
+ width: { type: 'integer' },
270
+ height: { type: 'integer' },
271
+ name: { type: 'string' },
272
+ },
273
+ },
274
+ },
275
+ },
276
+ },
277
+ },
278
+ },
279
+ render(args, value) {
280
+ const list = value.images || [value]
281
+ const lines = list.map((img) => `Generated image (${img.width}×${img.height}, ${img.format}, seed ${img.seed}) via ${value.provider || 'image-gen'}/${value.model || 'default'}: ${img.path}`)
282
+ const summary = lines.join('\n')
283
+ if (live().deliverAs !== 'image') {
284
+ const urls = list.map((img) => img.url).filter(Boolean)
285
+ return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
286
+ }
287
+ const blocks = [{ type: 'text', text: summary }]
288
+ for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
289
+ return blocks
290
+ },
291
+ },
292
+ isConcurrencySafe: () => false,
293
+ timeoutMs: (config?.timeoutMs || 180000) + 30000,
294
+ async execute(args, exec) {
295
+ const cfg = live()
296
+
297
+ // #168: Fail-fast loop guard protecting against runaway agent loops
298
+ const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
299
+ if (cfg.loopGuardLimit > 0) {
300
+ trackAndAssertLoopGuard(sessionId, {
301
+ limit: cfg.loopGuardLimit,
302
+ prompt: args.prompt,
303
+ exec,
304
+ })
305
+ }
306
+
307
+ const { provider, deliverAs, size, aspectPixels, format } = validateGenerationParams(cfg, args)
308
+
309
+ // Subscriptions service is optional: without it, subscription providers
310
+ // gracefully decline while other providers continue functioning.
311
+ let subscriptionImages
312
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
313
+
314
+ const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
315
+ const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
316
+
317
+ const {
318
+ enhanced,
319
+ effectivePrompt,
320
+ effectiveNegative,
321
+ effectiveGuidance,
322
+ activeAnchor,
323
+ resolvedFaceRef,
324
+ } = await prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource })
325
+
326
+ // #167: Assert daily budget availability before calling API
327
+ const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
328
+ const estimatedCost = calculateGenerationCost({
329
+ provider,
330
+ model: cfg.model || cfg.customModel,
331
+ size,
332
+ count: totalCount,
333
+ })
334
+ assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
335
+ const providers = makeProviders(
336
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
337
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
338
+ )
339
+ // Subscription providers return {ok:false, reason} instead of throwing:
340
+ // rejection reaches the model as text. Without this guard,
341
+ // execution would proceed with empty bytes.
342
+ const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
343
+ const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
344
+ const gen = await providers[providerKey](jobSeed, promptArg)
345
+ guard(gen)
346
+ return await saveGeneratedImageArtifact({
347
+ ctx,
348
+ exec,
349
+ cfg,
350
+ args,
351
+ gen,
352
+ promptArg,
353
+ jobSeed,
354
+ provider,
355
+ deliverAs,
356
+ size,
357
+ format,
358
+ effectiveNegative,
359
+ effectiveGuidance,
360
+ enhanced,
361
+ slugify,
362
+ estimatedCost,
363
+ totalCount,
364
+ })
365
+ }
366
+
367
+ const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
368
+ // Fallback cascade: evaluate configured provider, falling back to alternates
369
+ // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
370
+ const order = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
371
+ const qgEnabled = args.quality_gate ?? cfg.qualityGate
372
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
373
+ k,
374
+ async (s, p) => {
375
+ const { generated, qualityReport } = await executeWithQualityGate(
376
+ (seedVal) => one(seedVal, k, p),
377
+ {
378
+ initialSeed: s,
379
+ enabled: qgEnabled,
380
+ ctx,
381
+ prompt: p,
382
+ }
383
+ )
384
+ return { ...generated, qualityReport }
385
+ }
386
+ ]))
387
+ const images = []
388
+ const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
389
+ const checkCache = async (seedVal, promptVal) => {
390
+ // #357: force disables ALL caches (both disk cache and history cache)
391
+ if (args.force) return undefined
392
+
393
+ const effectiveModel = provider === 'custom'
394
+ ? (cfg.customModel || cfg.model || 'dall-e-3')
395
+ : (cfg.model || cfg.customModel)
396
+
397
+ const h = computeGenerationHash({
398
+ provider,
399
+ model: effectiveModel,
400
+ prompt: promptVal,
401
+ seed: seedVal,
402
+ size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
403
+ style: args.style || cfg.stylePreset,
404
+ format: args.output_format || cfg.defaultFormat || 'png',
405
+ aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
406
+ negativePrompt: args.negative_prompt || cfg.negativePrompt,
407
+ quality: args.quality,
408
+ strength: args.strength,
409
+ sourceImage: args.image || args.source_image,
410
+ maskImage: args.mask_image || args.mask,
411
+ reference: args.reference_images || args.reference_image,
412
+ })
413
+
414
+ // #170: Content-addressed disk cache check (<50ms return)
415
+ if (cfg.diskCache !== false) {
416
+ const diskHit = getCachedGeneration(h, { force: args.force })
417
+ if (diskHit) {
418
+ const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
419
+ const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
420
+ const name = `${stem}.${extension}`
421
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
422
+ const sessionCwd = exec.agent?.session?.header?.cwd
423
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
424
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
425
+ await mkdir(outDir, { recursive: true })
426
+ const filePath = path.join(outDir, name)
427
+ await writeFile(filePath, diskHit.bytes)
428
+
429
+ return {
430
+ path: filePath,
431
+ url: localUrl,
432
+ width: diskHit.width || attachment.width,
433
+ height: diskHit.height || attachment.height,
434
+ seed: seedVal,
435
+ prompt: promptVal,
436
+ cost: 0,
437
+ fromCache: true,
438
+ format: diskHit.mediaType.replace('image/', ''),
439
+ attachment: {
440
+ attachmentId: attachment.attachmentId,
441
+ mediaType: attachment.mediaType,
442
+ bytes: attachment.bytes,
443
+ width: attachment.width,
444
+ height: attachment.height,
445
+ name: attachment.name,
446
+ },
447
+ }
448
+ }
449
+ }
450
+
451
+ if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
452
+ const hPrompt = h
453
+ const found = findCachedGeneration(historyEntries, hPrompt)
454
+ if (found) return await cachedResult(found)
455
+ if (cfg.cacheBySeed) {
456
+ const bySeed = await findCached(historyEntries, seedVal, promptVal)
457
+ if (bySeed) return await cachedResult(bySeed)
458
+ }
459
+ if (cfg.cacheByPrompt) {
460
+ const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
461
+ if (byPrompt) return await cachedResult(byPrompt)
462
+ }
463
+ return undefined
464
+ }
465
+
466
+ const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
467
+ if (batchPrompts) {
468
+ const tasks = batchPrompts.map((item, i) => async () => {
469
+ const text = typeof item === 'string' ? item : (item && item.text) || ''
470
+ const cached = await checkCache(seedBase + i, text)
471
+ return cached || await executeWithFallback(generators, order, seedBase + i, text, { logger: ctx.logger })
472
+ })
473
+ const parallelResults = await asyncPool(tasks, 3)
474
+ images.push(...parallelResults)
475
+ } else {
476
+ const count = normalizeCount(args.count)
477
+ const tasks = Array.from({ length: count }, (_, i) => async () => {
478
+ const cached = await checkCache(seedBase + i, effectivePrompt)
479
+ return cached || await executeWithFallback(generators, order, seedBase + i, effectivePrompt, { logger: ctx.logger })
480
+ })
481
+ const parallelResults = await asyncPool(tasks, 3)
482
+ images.push(...parallelResults)
483
+ }
484
+ const first = images[0]
485
+ const dualOutputSummary = buildDualOutputMarkdown({
486
+ action: 'generated',
487
+ filePath: first.path,
488
+ width: first.width,
489
+ height: first.height,
490
+ mediaType: first.mediaType || ('image/' + (first.format || 'png')),
491
+ seed: first.seed,
492
+ provider,
493
+ model: cfg.model || cfg.customModel || 'default',
494
+ cost: first.cost,
495
+ attachmentId: first.attachment?.attachmentId || 'N/A',
496
+ })
497
+ return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
498
+ },
499
+ }),
500
+ )
501
+ }, 'dsh-image-gen: tool generate_image')
502
+ if (!options.coreOnly) {
503
+ registerGenerationPackTool(ctx, deps)
504
+ }
505
+ }