@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,250 +1,266 @@
1
- // lib/generation-helpers.js
2
- // Extracted execution and prompt/caching helpers for generate_image tool (#349).
3
-
4
- import path from 'node:path'
5
- import { mkdir, writeFile } from 'node:fs/promises'
6
- import {
7
- computeGenerationHash,
8
- IMAGE_SIZES,
9
- OUTPUT_FORMATS,
10
- PROVIDER_KEYS,
11
- buildSidecar,
12
- ASPECT_RATIOS,
13
- embedPngMetadata,
14
- resolveStylePreset,
15
- saveAttachmentSafe,
16
- } from './providers.js'
17
- import { enhancePrompt } from './prompt-enhancer.js'
18
- import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
19
- import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
20
- import { recordSpend } from './cost-meter.js'
21
- import { setCachedGeneration } from './generation-cache.js'
22
- import {
23
- readHistory,
24
- writeHistory,
25
- pruneHistory,
26
- } from './history.js'
27
-
28
- /** Run an array of async task functions with a concurrency cap */
29
- export async function asyncPool(tasks, concurrency = 3) {
30
- const results = new Array(tasks.length)
31
- let nextIdx = 0
32
- async function worker() {
33
- while (nextIdx < tasks.length) {
34
- const idx = nextIdx++
35
- results[idx] = await tasks[idx]()
36
- }
37
- }
38
- const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
39
- await Promise.all(workers)
40
- return results
41
- }
42
-
43
- /** Validates generation configuration and arguments */
44
- export function validateGenerationParams(cfg, args) {
45
- if (cfg.enabled === false) {
46
- throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
47
- }
48
- const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
49
- const deliverAs = cfg.deliverAs
50
- const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
51
- if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
52
- throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
53
- }
54
- const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
55
- const format = args.output_format ?? cfg.defaultFormat
56
- if (!OUTPUT_FORMATS.includes(format)) {
57
- throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
58
- }
59
- return { provider, deliverAs, size, aspectPixels, format }
60
- }
61
-
62
- /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
- export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
- const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
- const effectiveStyle = args.style || cfg.stylePreset
66
- const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
- let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
- let effectiveNegative = styleInfo.negativePrompt
69
- const activeAnchor = getSessionAnchor(sessionId)
70
- let resolvedFaceRef
71
-
72
- const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
73
- if (faceRefInput) {
74
- try {
75
- resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
76
- } catch (_err) {
77
- /* non-fatal: face_reference resolve fallback */
78
- resolvedFaceRef = { url: faceRefInput }
79
- }
80
- const facePlan = buildFaceConsistencyPrompt({
81
- prompt: effectivePrompt,
82
- faceStrength: args.face_strength || activeAnchor?.strength,
83
- characterName: activeAnchor?.label,
84
- })
85
- effectivePrompt = facePlan.prompt
86
- if (facePlan.negativePrompt) {
87
- effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
88
- }
89
- } else if (args.reference_image) {
90
- effectivePrompt = `${effectivePrompt}, visual reference anchor`
91
- } else if (activeAnchor) {
92
- effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
93
- }
94
-
95
- // #165: Negative prompt sanitizer for diffusion models
96
- if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
97
- effectiveNegative = sanitizeNegativePrompt({
98
- positivePrompt: effectivePrompt,
99
- userNegative: effectiveNegative,
100
- autoInjectDefects: true,
101
- })
102
- }
103
-
104
- return {
105
- enhanced,
106
- effectivePrompt,
107
- effectiveNegative,
108
- effectiveGuidance: styleInfo.guidanceScale,
109
- styleInfo,
110
- activeAnchor,
111
- resolvedFaceRef,
112
- }
113
- }
114
-
115
- /** Saves generated image to disk, sidecar, history, records spend, and builds return structure */
116
- export async function saveGeneratedImageArtifact({
117
- ctx,
118
- exec,
119
- cfg,
120
- args,
121
- gen,
122
- promptArg,
123
- jobSeed,
124
- provider,
125
- deliverAs,
126
- size,
127
- format,
128
- effectiveNegative,
129
- effectiveGuidance,
130
- enhanced,
131
- slugify,
132
- estimatedCost,
133
- totalCount,
134
- }) {
135
- const mediaType = gen.mediaType || 'image/png'
136
- let bytes = gen.bytes
137
- if (mediaType === 'image/png') {
138
- bytes = embedPngMetadata(bytes, {
139
- prompt: promptArg,
140
- seed: gen.seed,
141
- provider,
142
- model: cfg.model || cfg.customModel,
143
- negative_prompt: effectiveNegative,
144
- guidance_scale: effectiveGuidance,
145
- width: gen.width,
146
- height: gen.height,
147
- })
148
- }
149
- const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
150
- const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
151
- const name = `${stem}.${extension}`
152
-
153
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
154
-
155
- const sessionCwd = exec?.agent?.session?.header?.cwd
156
- const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
157
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
158
- await mkdir(outDir, { recursive: true })
159
- const filePath = path.join(outDir, name)
160
- await writeFile(filePath, bytes)
161
-
162
- // Sidecar: metadata written alongside image file, making the output
163
- // directory self-documenting without external database dependencies.
164
- await writeFile(
165
- path.join(outDir, `${stem}.json`),
166
- JSON.stringify(buildSidecar({
167
- prompt: promptArg,
168
- size,
169
- format,
170
- seed: gen.seed,
171
- provider,
172
- deliverAs,
173
- width: gen.width || attachment.width,
174
- height: gen.height || attachment.height,
175
- mediaType,
176
- attachmentId: attachment.attachmentId,
177
- url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
178
- cost: gen.cost,
179
- }), null, 2),
180
- )
181
-
182
- const cacheHash = computeGenerationHash({
183
- provider,
184
- model: cfg.model || cfg.customModel,
185
- prompt: promptArg,
186
- seed: gen.seed,
187
- size: args.image_size || cfg.defaultSize,
188
- style: args.style || cfg.stylePreset,
189
- })
190
-
191
- // #170: Store in content-addressed disk cache
192
- if (cfg.diskCache !== false) {
193
- setCachedGeneration(cacheHash, {
194
- bytes,
195
- mediaType,
196
- width: gen.width || attachment.width,
197
- height: gen.height || attachment.height,
198
- seed: gen.seed,
199
- meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
200
- })
201
- }
202
- const entry = {
203
- path: filePath,
204
- prompt: promptArg,
205
- provider,
206
- model: cfg.model,
207
- size,
208
- format,
209
- seed: gen.seed,
210
- cacheHash,
211
- thumbnailUrl: localUrl,
212
- width: gen.width || attachment.width,
213
- height: gen.height || attachment.height,
214
- mediaType,
215
- bytes: attachment.bytes,
216
- createdAt: new Date().toISOString(),
217
- attachmentId: attachment.attachmentId,
218
- }
219
- let current = await readHistory()
220
- current = await pruneHistory(current, cfg.pruneDays)
221
- current.unshift(entry)
222
- if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
223
- await writeHistory(current)
224
-
225
- // #167: Record spend in cost meter
226
- recordSpend(gen.cost || (estimatedCost / totalCount), {
227
- ctx,
228
- meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
229
- })
230
-
231
- return {
232
- path: filePath,
233
- url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
234
- width: gen.width || attachment.width,
235
- height: gen.height || attachment.height,
236
- seed: gen.seed,
237
- prompt: promptArg,
238
- ...(enhanced?.enhanced ? { originalPrompt: args.prompt } : {}),
239
- cost: gen.cost,
240
- format: mediaType.replace('image/', ''),
241
- attachment: {
242
- attachmentId: attachment.attachmentId,
243
- mediaType: attachment.mediaType,
244
- bytes: attachment.bytes,
245
- width: attachment.width,
246
- height: attachment.height,
247
- name: attachment.name,
248
- },
249
- }
250
- }
1
+ // lib/generation-helpers.js
2
+ // Extracted execution and prompt/caching helpers for generate_image tool (#349).
3
+
4
+ import path from 'node:path'
5
+ import { mkdir, writeFile } from 'node:fs/promises'
6
+ import {
7
+ computeGenerationHash,
8
+ IMAGE_SIZES,
9
+ OUTPUT_FORMATS,
10
+ PROVIDER_KEYS,
11
+ buildSidecar,
12
+ ASPECT_RATIOS,
13
+ embedPngMetadata,
14
+ resolveStylePreset,
15
+ saveAttachmentSafe,
16
+ } from './providers.js'
17
+ import { enhancePrompt } from './prompt-enhancer.js'
18
+ import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
19
+ import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
20
+ import { recordSpend } from './cost-meter.js'
21
+ import { setCachedGeneration } from './generation-cache.js'
22
+ import {
23
+ readHistory,
24
+ writeHistory,
25
+ pruneHistory,
26
+ } from './history.js'
27
+
28
+ /** Run an array of async task functions with a concurrency cap */
29
+ export async function asyncPool(tasks, concurrency = 3) {
30
+ const results = new Array(tasks.length)
31
+ let nextIdx = 0
32
+ async function worker() {
33
+ while (nextIdx < tasks.length) {
34
+ const idx = nextIdx++
35
+ results[idx] = await tasks[idx]()
36
+ }
37
+ }
38
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
39
+ await Promise.all(workers)
40
+ return results
41
+ }
42
+
43
+ /** Validates generation configuration and arguments */
44
+ export function validateGenerationParams(cfg, args) {
45
+ if (cfg.enabled === false) {
46
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
47
+ }
48
+ const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
49
+ const deliverAs = cfg.deliverAs
50
+ const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
51
+ if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
52
+ throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
53
+ }
54
+ const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
55
+ const format = args.output_format ?? cfg.defaultFormat
56
+ if (!OUTPUT_FORMATS.includes(format)) {
57
+ throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
58
+ }
59
+ return { provider, deliverAs, size, aspectPixels, format }
60
+ }
61
+
62
+ /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
+ export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
+ const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
+ const effectiveStyle = args.style || cfg.stylePreset
66
+ const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
+ let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
+ let effectiveNegative = styleInfo.negativePrompt
69
+ const activeAnchor = getSessionAnchor(sessionId)
70
+ let resolvedFaceRef
71
+
72
+ const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
73
+ if (faceRefInput) {
74
+ try {
75
+ resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
76
+ } catch (_err) {
77
+ /* non-fatal: face_reference resolve fallback */
78
+ resolvedFaceRef = { url: faceRefInput }
79
+ }
80
+ const facePlan = buildFaceConsistencyPrompt({
81
+ prompt: effectivePrompt,
82
+ faceStrength: args.face_strength || activeAnchor?.strength,
83
+ characterName: activeAnchor?.label,
84
+ })
85
+ effectivePrompt = facePlan.prompt
86
+ if (facePlan.negativePrompt) {
87
+ effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
88
+ }
89
+ } else if (args.reference_image) {
90
+ effectivePrompt = `${effectivePrompt}, visual reference anchor`
91
+ } else if (activeAnchor) {
92
+ effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
93
+ }
94
+
95
+ // #165: Negative prompt sanitizer for diffusion models
96
+ if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
97
+ effectiveNegative = sanitizeNegativePrompt({
98
+ positivePrompt: effectivePrompt,
99
+ userNegative: effectiveNegative,
100
+ autoInjectDefects: true,
101
+ })
102
+ }
103
+
104
+ return {
105
+ enhanced,
106
+ effectivePrompt,
107
+ effectiveNegative,
108
+ effectiveGuidance: styleInfo.guidanceScale,
109
+ styleInfo,
110
+ activeAnchor,
111
+ resolvedFaceRef,
112
+ }
113
+ }
114
+
115
+ /** Saves generated image to disk, sidecar, history, records spend, and builds return structure */
116
+ export async function saveGeneratedImageArtifact({
117
+ ctx,
118
+ exec,
119
+ cfg,
120
+ args,
121
+ gen,
122
+ promptArg,
123
+ jobSeed,
124
+ provider,
125
+ deliverAs,
126
+ size,
127
+ format,
128
+ effectiveNegative,
129
+ effectiveGuidance,
130
+ enhanced,
131
+ slugify,
132
+ estimatedCost,
133
+ totalCount,
134
+ }) {
135
+ const mediaType = gen.mediaType || 'image/png'
136
+ let bytes = gen.bytes
137
+ if (mediaType === 'image/png') {
138
+ bytes = embedPngMetadata(bytes, {
139
+ prompt: promptArg,
140
+ seed: gen.seed,
141
+ provider,
142
+ model: cfg.model || cfg.customModel,
143
+ negative_prompt: effectiveNegative,
144
+ guidance_scale: effectiveGuidance,
145
+ width: gen.width,
146
+ height: gen.height,
147
+ })
148
+ }
149
+ const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
150
+ const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
151
+ const name = `${stem}.${extension}`
152
+
153
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
154
+
155
+ const sessionCwd = exec?.agent?.session?.header?.cwd
156
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
157
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
158
+ await mkdir(outDir, { recursive: true })
159
+ const filePath = path.join(outDir, name)
160
+ await writeFile(filePath, bytes)
161
+
162
+ // Sidecar: metadata written alongside image file, making the output
163
+ // directory self-documenting without external database dependencies.
164
+ await writeFile(
165
+ path.join(outDir, `${stem}.json`),
166
+ JSON.stringify(buildSidecar({
167
+ prompt: promptArg,
168
+ size,
169
+ format,
170
+ seed: gen.seed,
171
+ provider,
172
+ deliverAs,
173
+ width: gen.width || attachment.width,
174
+ height: gen.height || attachment.height,
175
+ mediaType,
176
+ attachmentId: attachment.attachmentId,
177
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
178
+ cost: gen.cost,
179
+ }), null, 2),
180
+ )
181
+
182
+ const effectiveModel = provider === 'custom'
183
+ ? (cfg.customModel || cfg.model || 'dall-e-3')
184
+ : (cfg.model || cfg.customModel)
185
+
186
+ const cacheHash = computeGenerationHash({
187
+ provider,
188
+ model: effectiveModel,
189
+ prompt: promptArg,
190
+ seed: gen.seed,
191
+ size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
192
+ style: args.style || cfg.stylePreset,
193
+ format: format || args.output_format || cfg.defaultFormat || 'png',
194
+ aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
195
+ negativePrompt: args.negative_prompt || cfg.negativePrompt,
196
+ quality: args.quality,
197
+ strength: args.strength,
198
+ sourceImage: args.image || args.source_image,
199
+ maskImage: args.mask_image || args.mask,
200
+ reference: args.reference_images || args.reference_image,
201
+ })
202
+
203
+ // #170: Store in content-addressed disk cache
204
+ if (cfg.diskCache !== false) {
205
+ setCachedGeneration(cacheHash, {
206
+ bytes,
207
+ mediaType,
208
+ width: gen.width || attachment.width,
209
+ height: gen.height || attachment.height,
210
+ seed: gen.seed,
211
+ meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
212
+ })
213
+ }
214
+ const entry = {
215
+ path: filePath,
216
+ prompt: promptArg,
217
+ provider,
218
+ model: cfg.model,
219
+ size,
220
+ format,
221
+ seed: gen.seed,
222
+ cacheHash,
223
+ thumbnailUrl: localUrl,
224
+ width: gen.width || attachment.width,
225
+ height: gen.height || attachment.height,
226
+ mediaType,
227
+ bytes: attachment.bytes,
228
+ createdAt: new Date().toISOString(),
229
+ attachmentId: attachment.attachmentId,
230
+ }
231
+ // Save to history (#385: historyLimit=0 skips history retention; bounds guard against negative values)
232
+ const historyLimit = typeof cfg.historyLimit === 'number' ? Math.max(0, Math.floor(cfg.historyLimit)) : 50
233
+ if (historyLimit > 0) {
234
+ let current = await readHistory()
235
+ current = await pruneHistory(current, cfg.pruneDays)
236
+ current.unshift(entry)
237
+ if (current.length > historyLimit) current.length = historyLimit
238
+ await writeHistory(current)
239
+ }
240
+
241
+ // #167: Record spend in cost meter
242
+ recordSpend(gen.cost || (estimatedCost / totalCount), {
243
+ ctx,
244
+ meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
245
+ })
246
+
247
+ return {
248
+ path: filePath,
249
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
250
+ width: gen.width || attachment.width,
251
+ height: gen.height || attachment.height,
252
+ seed: gen.seed,
253
+ prompt: promptArg,
254
+ ...(enhanced?.enhanced ? { originalPrompt: args.prompt } : {}),
255
+ cost: gen.cost,
256
+ format: mediaType.replace('image/', ''),
257
+ attachment: {
258
+ attachmentId: attachment.attachmentId,
259
+ mediaType: attachment.mediaType,
260
+ bytes: attachment.bytes,
261
+ width: attachment.width,
262
+ height: attachment.height,
263
+ name: attachment.name,
264
+ },
265
+ }
266
+ }
package/lib/index.js CHANGED
@@ -371,10 +371,20 @@ export function apply(ctx, rawConfig) {
371
371
  // Vault route (#159)
372
372
  registerVaultRoutes(ctx)
373
373
 
374
- // Session and cache teardown cleanup effect
374
+ // Reset loop guard when user sends a new message (#370)
375
+ if (typeof ctx.on === 'function') {
376
+ ctx.on('chat/message', (session) => {
377
+ if (session?.id) resetLoopGuard(session.id)
378
+ })
379
+ ctx.on('message', (session) => {
380
+ if (session?.id) resetLoopGuard(session.id)
381
+ })
382
+ }
383
+
384
+ // Session and cache teardown cleanup effect (#370)
375
385
  ctx.effect(() => () => {
376
386
  clearAllAnchors()
377
- resetLoopGuard()
387
+ resetLoopGuard() // clears ALL session states from memory
378
388
  }, 'dsh-image-gen: session cleanup')
379
389
 
380
390
  // Tool registrations live in register-tools.js; each tool is a labeled ctx.effect (#216).
package/lib/loop-guard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // lib/loop-guard.js
2
- // Fail-fast loop guard protecting against runaway agent retry loops (#168)
2
+ // Fail-fast loop guard protecting against runaway agent retry loops (#168, #370)
3
3
 
4
- /** In-memory session tracking map: sessionId -> { count: number, lastPrompt: string, lastTimestamp: number } */
4
+ /** In-memory session tracking map: sessionId -> { count: number, lastPrompt: string, lastTimestamp: number, lastTurnId: any } */
5
5
  const sessionStates = new Map()
6
6
 
7
7
  /** Default maximum consecutive generations without user interaction */
@@ -15,12 +15,16 @@ export const DEFAULT_MAX_CONSECUTIVE_GENERATIONS = 3
15
15
  * @param {number} [options.limit=3] - Maximum allowed calls
16
16
  * @param {string} [options.prompt=''] - Current prompt
17
17
  * @param {boolean} [options.isUserTurn=false] - Whether the user just submitted a turn (resets counter)
18
+ * @param {any} [options.turnId=null] - Canonical turn / message identifier
19
+ * @param {Object} [options.exec=null] - Tool execution context
18
20
  * @throws {Error} If consecutive generation limit is exceeded.
19
21
  */
20
22
  export function trackAndAssertLoopGuard(sessionId, {
21
23
  limit = DEFAULT_MAX_CONSECUTIVE_GENERATIONS,
22
24
  prompt = '',
23
25
  isUserTurn = false,
26
+ turnId = null,
27
+ exec = null,
24
28
  } = {}) {
25
29
  const sid = String(sessionId || 'default_session')
26
30
 
@@ -34,9 +38,27 @@ export function trackAndAssertLoopGuard(sessionId, {
34
38
  return { count: 0, remaining: Infinity, allowed: true }
35
39
  }
36
40
 
37
- const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
41
+ const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0, lastTurnId: null }
38
42
  const maxLimit = Math.max(1, Number(limit) || DEFAULT_MAX_CONSECUTIVE_GENERATIONS)
39
43
 
44
+ // Derive turn identifier from turnId or exec context
45
+ const session = exec?.agent?.session
46
+ const derivedTurnId = turnId ??
47
+ exec?.turnId ??
48
+ exec?.agent?.turnId ??
49
+ exec?.message?.id ??
50
+ session?.lastMessageId ??
51
+ session?.turnId ??
52
+ (Array.isArray(session?.messages) ? session.messages.length : null)
53
+
54
+ // If turn identifier changed, reset count for the new user turn
55
+ if (derivedTurnId !== null && derivedTurnId !== undefined) {
56
+ if (current.lastTurnId !== null && current.lastTurnId !== undefined && current.lastTurnId !== derivedTurnId) {
57
+ current.count = 0
58
+ }
59
+ current.lastTurnId = derivedTurnId
60
+ }
61
+
40
62
  if (current.count >= maxLimit) {
41
63
  throw new Error(
42
64
  `Generation loop limit reached (${current.count}/${maxLimit} consecutive generations). ` +
@@ -53,19 +75,21 @@ export function trackAndAssertLoopGuard(sessionId, {
53
75
  }
54
76
 
55
77
  /**
56
- * Resets loop guard counter for a session.
78
+ * Resets loop guard counter for a session, or clears all session states if sessionId is omitted.
57
79
  */
58
- /** @internal — test helper; not part of the plugin runtime API */
59
80
  export function resetLoopGuard(sessionId) {
60
- const sid = String(sessionId || 'default_session')
81
+ if (!sessionId) {
82
+ sessionStates.clear()
83
+ return
84
+ }
85
+ const sid = String(sessionId)
61
86
  sessionStates.delete(sid)
62
87
  }
63
88
 
64
89
  /**
65
90
  * Gets current state for debugging/testing.
66
91
  */
67
- /** @internal — test helper; not part of the plugin runtime API */
68
92
  export function getLoopGuardState(sessionId) {
69
93
  const sid = String(sessionId || 'default_session')
70
- return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
94
+ return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0, lastTurnId: null }
71
95
  }