dspy-deep_research 1.0.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,616 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepResearch
5
+ class Module < DSPy::Module
6
+ extend T::Sig
7
+
8
+ SectionSpec = DSPy::DeepResearch::Signatures::BuildOutline::SectionSpec
9
+ ResearchMode = DSPy::DeepResearch::Signatures::BuildOutline::Mode
10
+
11
+ MODEL_ENV_KEYS = {
12
+ planner: 'DSPY_DEEP_RESEARCH_PLANNER_MODEL',
13
+ qa: 'DSPY_DEEP_RESEARCH_QA_MODEL',
14
+ synthesizer: 'DSPY_DEEP_RESEARCH_SYNTH_MODEL',
15
+ reporter: 'DSPY_DEEP_RESEARCH_REPORTER_MODEL'
16
+ }.freeze
17
+
18
+ MODEL_PRIORITY = {
19
+ planner: [
20
+ 'gemini/gemini-2.5-pro',
21
+ 'openai/o4-mini',
22
+ 'anthropic/claude-4.1-opus'
23
+ ],
24
+ qa: [
25
+ 'gemini/gemini-2.5-pro',
26
+ 'openai/o4-mini',
27
+ 'anthropic/claude-4.1-opus'
28
+ ],
29
+ synthesizer: [
30
+ 'anthropic/claude-sonnet-4-5',
31
+ 'openai/gpt-4.1'
32
+ ],
33
+ reporter: [
34
+ 'anthropic/claude-sonnet-4-5',
35
+ 'openai/gpt-4.1'
36
+ ]
37
+ }.freeze
38
+
39
+ MODE_CONFIG = T.let(
40
+ {
41
+ ResearchMode::Light => T.let(1, Integer),
42
+ ResearchMode::Medium => T.let(3, Integer),
43
+ ResearchMode::Hard => T.let(5, Integer),
44
+ ResearchMode::Ultra => T.let(6, Integer)
45
+ }.freeze,
46
+ T::Hash[ResearchMode, Integer]
47
+ )
48
+
49
+ DEFAULT_MODE = ResearchMode::Medium
50
+
51
+ class SectionResult < T::Struct
52
+ class Status < T::Enum
53
+ enums do
54
+ Complete = new("complete")
55
+ Partial = new("partial")
56
+ InsufficientEvidence = new("insufficient_evidence")
57
+ end
58
+ end
59
+
60
+ const :identifier, String
61
+ const :title, String
62
+ const :draft, String
63
+ const :citations, T::Array[String]
64
+ const :warnings, T::Array[String], default: []
65
+ const :status, Status, default: Status::Complete
66
+ const :attempt, Integer
67
+ end
68
+
69
+ class Result < T::Struct
70
+ const :report, String
71
+ const :sections, T::Array[SectionResult]
72
+ const :citations, T::Array[String]
73
+ const :warnings, T::Array[String], default: []
74
+ const :budget_exhausted, T::Boolean, default: false
75
+ end
76
+
77
+ sig do
78
+ params(
79
+ planner: T.untyped,
80
+ deep_search_factory: T.nilable(T.proc.returns(DSPy::Module)),
81
+ synthesizer: T.untyped,
82
+ qa_reviewer: T.untyped,
83
+ reporter: T.untyped,
84
+ section_queue_factory: T.nilable(T.proc.returns(DSPy::DeepResearch::SectionQueue)),
85
+ max_section_attempts: Integer
86
+ ).void
87
+ end
88
+ def initialize(
89
+ planner: DSPy::Predict.new(DSPy::DeepResearch::Signatures::BuildOutline),
90
+ deep_search_factory: nil,
91
+ synthesizer: DSPy::Predict.new(DSPy::DeepResearch::Signatures::SynthesizeSection),
92
+ qa_reviewer: DSPy::Predict.new(DSPy::DeepResearch::Signatures::QAReview),
93
+ reporter: DSPy::Predict.new(DSPy::DeepResearch::Signatures::AssembleReport),
94
+ section_queue_factory: nil,
95
+ max_section_attempts: 3
96
+ )
97
+ super()
98
+
99
+ @planner = planner
100
+ @deep_search_factory = deep_search_factory || default_deep_search_factory
101
+ @synthesizer = synthesizer
102
+ @qa_reviewer = qa_reviewer
103
+ @reporter = reporter
104
+ @section_queue_factory = section_queue_factory || -> { DSPy::DeepResearch::SectionQueue.new }
105
+ @max_section_attempts = max_section_attempts
106
+ @deep_search_instruction = nil
107
+ @deep_search_examples = []
108
+
109
+ reset_state!
110
+ configure_default_predictor_models
111
+ end
112
+
113
+ sig { override.returns(T::Array[[String, DSPy::Module]]) }
114
+ def named_predictors
115
+ [
116
+ ["planner", @planner],
117
+ ["synthesizer", @synthesizer],
118
+ ["qa_reviewer", @qa_reviewer],
119
+ ["reporter", @reporter]
120
+ ]
121
+ end
122
+
123
+ sig { override.returns(T::Array[DSPy::Module]) }
124
+ def predictors
125
+ named_predictors.map { |(_, predictor)| predictor }
126
+ end
127
+
128
+ sig { params(instruction: String).returns(Module) }
129
+ def with_instruction(instruction)
130
+ clone_with(
131
+ planner: apply_instruction(@planner, instruction),
132
+ synthesizer: apply_instruction(@synthesizer, instruction),
133
+ qa_reviewer: apply_instruction(@qa_reviewer, instruction),
134
+ reporter: apply_instruction(@reporter, instruction),
135
+ deep_search_instruction: instruction,
136
+ deep_search_examples: @deep_search_examples.dup
137
+ )
138
+ end
139
+
140
+ sig { params(examples: T::Array[DSPy::FewShotExample]).returns(Module) }
141
+ def with_examples(examples)
142
+ examples_copy = examples.map { |example| example }
143
+ clone_with(
144
+ planner: apply_examples(@planner, examples_copy),
145
+ synthesizer: apply_examples(@synthesizer, examples_copy),
146
+ qa_reviewer: apply_examples(@qa_reviewer, examples_copy),
147
+ reporter: apply_examples(@reporter, examples_copy),
148
+ deep_search_instruction: @deep_search_instruction,
149
+ deep_search_examples: examples_copy
150
+ )
151
+ end
152
+
153
+ def forward_untyped(**input_values)
154
+ brief = input_values[:brief]
155
+ unless brief.is_a?(String)
156
+ raise ArgumentError, "DeepResearch expects keyword argument :brief"
157
+ end
158
+
159
+ mode = normalize_mode(input_values[:mode])
160
+
161
+ reset_state!
162
+ @current_mode = mode
163
+ @current_mode_target_sections = mode_target_sections(mode)
164
+
165
+ outline = @planner.call(brief: brief, mode: mode)
166
+ sections = apply_mode_to_sections(outline.sections, @current_mode_target_sections)
167
+ enqueue_sections(sections)
168
+
169
+ while (section_spec = @section_queue.dequeue)
170
+ attempts = @section_queue.attempts_for(section_spec)
171
+ if attempts > @max_section_attempts
172
+ raise DSPy::DeepResearch::QueueStarvationError,
173
+ "Section #{section_spec.identifier} exceeded max attempts (#{attempts}/#{@max_section_attempts})"
174
+ end
175
+
176
+ emit_section_started(section_spec, attempts)
177
+
178
+ deep_search_module = build_deep_search(section_spec)
179
+ deep_result = deep_search_module.call(question: section_spec.prompt)
180
+ @token_budget_exhausted ||= deep_result.budget_exhausted
181
+
182
+ evidence = merge_section_evidence(section_spec, deep_result)
183
+
184
+ synthesized = @synthesizer.call(
185
+ brief: brief,
186
+ section: section_spec,
187
+ answer: deep_result.answer,
188
+ notes: evidence[:notes],
189
+ citations: evidence[:citations]
190
+ )
191
+
192
+ citations = Array(synthesized.citations || evidence[:citations]).compact.uniq
193
+ warnings = section_warnings(evidence, deep_result)
194
+ base_status = deep_result.budget_exhausted ? SectionResult::Status::Partial : SectionResult::Status::Complete
195
+
196
+ qa_decision = @qa_reviewer.call(
197
+ brief: brief,
198
+ section: section_spec,
199
+ draft: synthesized.draft,
200
+ notes: evidence[:notes],
201
+ citations: evidence[:citations],
202
+ attempt: attempts
203
+ )
204
+
205
+ case qa_decision.status
206
+ when DSPy::DeepResearch::Signatures::QAReview::Status::Approved
207
+ section_result = build_section_result(section_spec, synthesized, citations, attempts, base_status, warnings)
208
+ accept_section(section_result)
209
+ when DSPy::DeepResearch::Signatures::QAReview::Status::NeedsMoreEvidence
210
+ follow_up_prompt = qa_decision.follow_up_prompt
211
+
212
+ if deep_result.budget_exhausted
213
+ warnings << insufficient_evidence_warning(section_spec)
214
+ section_result = build_section_result(
215
+ section_spec,
216
+ synthesized,
217
+ citations,
218
+ attempts,
219
+ SectionResult::Status::InsufficientEvidence,
220
+ warnings
221
+ )
222
+ accept_section(section_result)
223
+ emit_section_insufficient(section_spec, attempts, warnings.last)
224
+ next
225
+ end
226
+
227
+ if attempts >= @max_section_attempts
228
+ raise DSPy::DeepResearch::EvidenceDeficitError,
229
+ "QA requested more evidence for #{section_spec.title} beyond max attempts"
230
+ end
231
+
232
+ unless follow_up_prompt
233
+ raise DSPy::DeepResearch::EvidenceDeficitError,
234
+ "QA requested more evidence for #{section_spec.title} but no follow-up prompt provided"
235
+ end
236
+
237
+ emit_section_retry(section_spec, attempts, follow_up_prompt)
238
+
239
+ follow_up = @section_queue.enqueue_follow_up(section_spec, prompt: follow_up_prompt)
240
+ DSPy.event(
241
+ "deep_research.section.requeued",
242
+ identifier: section_spec.identifier,
243
+ follow_up_identifier: follow_up.identifier,
244
+ prompt: follow_up_prompt,
245
+ attempt: follow_up.attempt
246
+ )
247
+ else
248
+ raise DSPy::DeepResearch::SynthesisCoherenceError,
249
+ "Unexpected QA status: #{qa_decision.status}"
250
+ end
251
+ end
252
+
253
+ raise DSPy::DeepResearch::SynthesisCoherenceError, "No sections were approved" if @accepted_sections.empty?
254
+
255
+ assembled = @reporter.call(
256
+ brief: brief,
257
+ sections: @accepted_sections.map do |section|
258
+ DSPy::DeepResearch::Signatures::AssembleReport::SectionDraft.new(
259
+ identifier: section.identifier,
260
+ title: section.title,
261
+ draft: section.draft,
262
+ citations: section.citations
263
+ )
264
+ end
265
+ )
266
+
267
+ result = Result.new(
268
+ report: assembled.report,
269
+ sections: @accepted_sections.dup,
270
+ citations: merged_citations(Array(assembled.citations)),
271
+ warnings: @warnings.dup,
272
+ budget_exhausted: @token_budget_exhausted
273
+ )
274
+ ensure_report_ready(assembled, brief)
275
+ result
276
+ end
277
+
278
+ private
279
+
280
+ sig do
281
+ params(
282
+ planner: T.untyped,
283
+ synthesizer: T.untyped,
284
+ qa_reviewer: T.untyped,
285
+ reporter: T.untyped,
286
+ deep_search_instruction: T.nilable(String),
287
+ deep_search_examples: T::Array[DSPy::FewShotExample]
288
+ ).returns(Module)
289
+ end
290
+ def clone_with(planner:, synthesizer:, qa_reviewer:, reporter:, deep_search_instruction:, deep_search_examples:)
291
+ clone = self.class.new(
292
+ planner: planner,
293
+ deep_search_factory: @deep_search_factory,
294
+ synthesizer: synthesizer,
295
+ qa_reviewer: qa_reviewer,
296
+ reporter: reporter,
297
+ section_queue_factory: @section_queue_factory,
298
+ max_section_attempts: @max_section_attempts
299
+ )
300
+
301
+ clone.instance_variable_set(:@deep_search_instruction, deep_search_instruction)
302
+ clone.instance_variable_set(:@deep_search_examples, deep_search_examples)
303
+ clone
304
+ end
305
+
306
+ sig { params(predictor: T.untyped, instruction: String).returns(T.untyped) }
307
+ def apply_instruction(predictor, instruction)
308
+ return predictor unless predictor.respond_to?(:with_instruction)
309
+
310
+ predictor.with_instruction(instruction)
311
+ end
312
+
313
+ sig { params(predictor: T.untyped, examples: T::Array[DSPy::FewShotExample]).returns(T.untyped) }
314
+ def apply_examples(predictor, examples)
315
+ return predictor unless predictor.respond_to?(:with_examples)
316
+
317
+ predictor.with_examples(examples)
318
+ end
319
+
320
+ sig { params(sections: T::Array[SectionSpec]).void }
321
+ def enqueue_sections(sections)
322
+ sections.each do |section|
323
+ @section_queue.enqueue(section)
324
+ DSPy.event(
325
+ "deep_research.section.enqueued",
326
+ identifier: section.identifier,
327
+ title: section.title,
328
+ prompt: section.prompt,
329
+ token_budget: section.token_budget
330
+ )
331
+ end
332
+ end
333
+
334
+ sig { params(section: SectionResult).void }
335
+ def accept_section(section)
336
+ @accepted_sections << section
337
+ @citations.concat(section.citations)
338
+ @warnings.concat(section.warnings)
339
+ @warnings.uniq!
340
+
341
+ DSPy.event(
342
+ "deep_research.section.approved",
343
+ identifier: section.identifier,
344
+ title: section.title,
345
+ attempt: section.attempt,
346
+ citations: section.citations
347
+ )
348
+
349
+ emit_section_completion_status(section)
350
+ end
351
+
352
+ sig { params(section: SectionResult).void }
353
+ def emit_section_completion_status(section)
354
+ return if section.status == SectionResult::Status::Complete
355
+
356
+ DSPy.event(
357
+ "deep_research.section.partial",
358
+ identifier: section.identifier,
359
+ title: section.title,
360
+ status: section.status.serialize,
361
+ warnings: section.warnings
362
+ )
363
+ end
364
+
365
+ sig { params(section: SectionSpec).returns(DSPy::Module) }
366
+ def build_deep_search(section)
367
+ module_instance = @deep_search_factory.call
368
+ if @deep_search_instruction && module_instance.respond_to?(:with_instruction)
369
+ module_instance = module_instance.with_instruction(@deep_search_instruction)
370
+ end
371
+ unless @deep_search_examples.empty?
372
+ if module_instance.respond_to?(:with_examples)
373
+ module_instance = module_instance.with_examples(@deep_search_examples)
374
+ end
375
+ end
376
+
377
+ module_instance
378
+ end
379
+
380
+ sig { returns(T.proc.returns(DSPy::Module)) }
381
+ def default_deep_search_factory
382
+ -> { DSPy::DeepSearch::Module.new }
383
+ end
384
+
385
+ sig { void }
386
+ def reset_state!
387
+ @section_queue = @section_queue_factory.call
388
+ @accepted_sections = T.let([], T::Array[SectionResult])
389
+ @citations = T.let([], T::Array[String])
390
+ @section_evidence = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]])
391
+ @warnings = T.let([], T::Array[String])
392
+ @token_budget_exhausted = T.let(false, T::Boolean)
393
+ @current_mode = T.let(DEFAULT_MODE, ResearchMode)
394
+ @current_mode_target_sections = T.let(mode_target_sections(DEFAULT_MODE), Integer)
395
+ end
396
+
397
+ sig { params(citations: T::Array[String]).returns(T::Array[String]) }
398
+ def merged_citations(citations)
399
+ (Array(citations) + @citations).compact.uniq
400
+ end
401
+
402
+ sig { params(section: SectionSpec, deep_result: DSPy::DeepSearch::Module::Result).returns(T::Hash[Symbol, T.untyped]) }
403
+ def merge_section_evidence(section, deep_result)
404
+ base = normalize_identifier(section)
405
+ store = (@section_evidence[base] ||= { notes: [], citations: [], warnings: [], budget_exhausted: false })
406
+ store[:notes] = (Array(store[:notes]) + Array(deep_result.notes)).compact.uniq
407
+ store[:citations] = (Array(store[:citations]) + Array(deep_result.citations)).compact.uniq
408
+ if deep_result.warning
409
+ warnings = Array(store[:warnings]) + [deep_result.warning]
410
+ store[:warnings] = warnings.compact.uniq
411
+ else
412
+ store[:warnings] = Array(store[:warnings])
413
+ end
414
+ store[:budget_exhausted] ||= deep_result.budget_exhausted
415
+ store
416
+ end
417
+
418
+ sig { params(section: SectionSpec).returns(String) }
419
+ def normalize_identifier(section)
420
+ section.parent_identifier || section.identifier.split("-retry-").first
421
+ end
422
+
423
+ sig { params(section: SectionSpec, attempt: Integer).void }
424
+ def emit_section_started(section, attempt)
425
+ DSPy.event(
426
+ "deep_research.section.started",
427
+ identifier: section.identifier,
428
+ title: section.title,
429
+ prompt: section.prompt,
430
+ attempt: attempt
431
+ )
432
+ end
433
+
434
+ sig { params(section: SectionSpec, attempt: Integer, follow_up_prompt: String).void }
435
+ def emit_section_retry(section, attempt, follow_up_prompt)
436
+ DSPy.event(
437
+ "deep_research.section.qa_retry",
438
+ identifier: section.identifier,
439
+ title: section.title,
440
+ attempt: attempt,
441
+ follow_up_prompt: follow_up_prompt
442
+ )
443
+ end
444
+
445
+ sig { params(section: SectionSpec, attempt: Integer, warning: String).void }
446
+ def emit_section_insufficient(section, attempt, warning)
447
+ DSPy.event(
448
+ "deep_research.section.insufficient_evidence",
449
+ identifier: section.identifier,
450
+ title: section.title,
451
+ attempt: attempt,
452
+ warning: warning
453
+ )
454
+ end
455
+
456
+ sig { params(assembled: T.untyped, brief: String).void }
457
+ def ensure_report_ready(assembled, brief)
458
+ DSPy.event(
459
+ "deep_research.report.ready",
460
+ brief: brief,
461
+ section_count: @accepted_sections.length,
462
+ citation_count: assembled.citations&.length || 0
463
+ )
464
+ end
465
+
466
+ sig { params(section_spec: SectionSpec, synthesized: T.untyped, citations: T::Array[String], attempts: Integer, status: SectionResult::Status, warnings: T::Array[String]).returns(SectionResult) }
467
+ def build_section_result(section_spec, synthesized, citations, attempts, status, warnings)
468
+ SectionResult.new(
469
+ identifier: section_spec.identifier,
470
+ title: section_spec.title,
471
+ draft: synthesized.draft,
472
+ citations: citations,
473
+ warnings: warnings.dup,
474
+ status: status,
475
+ attempt: attempts
476
+ )
477
+ end
478
+
479
+ sig { params(evidence: T::Hash[Symbol, T.untyped], deep_result: DSPy::DeepSearch::Module::Result).returns(T::Array[String]) }
480
+ def section_warnings(evidence, deep_result)
481
+ warnings = Array(evidence[:warnings]).dup
482
+ warnings << deep_result.warning if deep_result.warning
483
+ warnings.compact!
484
+ warnings.uniq!
485
+ if deep_result.budget_exhausted && warnings.empty?
486
+ warnings << "Token budget exhausted while collecting evidence"
487
+ end
488
+ warnings
489
+ end
490
+
491
+ sig { params(section: SectionSpec).returns(String) }
492
+ def insufficient_evidence_warning(section)
493
+ "Token budget exhausted before QA approval for #{section.title}"
494
+ end
495
+
496
+ sig { params(raw_mode: T.untyped).returns(ResearchMode) }
497
+ def normalize_mode(raw_mode)
498
+ return DEFAULT_MODE if raw_mode.nil?
499
+ return raw_mode if raw_mode.is_a?(ResearchMode)
500
+
501
+ ResearchMode.deserialize(raw_mode.to_s)
502
+ rescue ArgumentError
503
+ DEFAULT_MODE
504
+ end
505
+
506
+ sig { params(mode: ResearchMode).returns(Integer) }
507
+ def mode_target_sections(mode)
508
+ MODE_CONFIG.fetch(mode) { MODE_CONFIG.fetch(DEFAULT_MODE) }
509
+ end
510
+
511
+ sig { params(sections: T::Array[SectionSpec], limit: Integer).returns(T::Array[SectionSpec]) }
512
+ def apply_mode_to_sections(sections, limit)
513
+ sections.first(limit).map do |section|
514
+ SectionSpec.new(
515
+ identifier: section.identifier,
516
+ title: section.title,
517
+ prompt: section.prompt,
518
+ token_budget: section.token_budget,
519
+ attempt: section.attempt,
520
+ parent_identifier: section.parent_identifier
521
+ )
522
+ end
523
+ end
524
+
525
+ def configure_default_predictor_models
526
+ @lm_cache = {}
527
+ assign_model(@planner, :planner)
528
+ assign_model(@qa_reviewer, :qa)
529
+ assign_model(@synthesizer, :synthesizer)
530
+ assign_model(@reporter, :reporter)
531
+ end
532
+
533
+ def env_model(role)
534
+ key = MODEL_ENV_KEYS[role]
535
+ value = key ? ENV[key] : nil
536
+ return nil if value.nil?
537
+
538
+ trimmed = value.strip
539
+ trimmed.empty? ? nil : trimmed
540
+ end
541
+
542
+ def assign_model(predictor, role)
543
+ return unless predictor
544
+ return if predictor.respond_to?(:config) && predictor.config.lm
545
+
546
+ candidates = []
547
+ env_override = env_model(role)
548
+ candidates << env_override if env_override
549
+ candidates.concat(Array(MODEL_PRIORITY[role]))
550
+
551
+ candidates.each do |model_id|
552
+ next unless model_id
553
+ lm = build_lm(model_id)
554
+ next unless lm
555
+
556
+ begin
557
+ predictor.configure { |config| config.lm = lm }
558
+ return
559
+ rescue StandardError => e
560
+ DSPy.logger&.warn(
561
+ "DeepResearch predictor LM assignment error",
562
+ role: role,
563
+ model: model_id,
564
+ error: e.message
565
+ )
566
+ end
567
+ end
568
+
569
+ DSPy.logger&.warn(
570
+ "DeepResearch predictor LM assignment skipped (no viable model)",
571
+ role: role
572
+ )
573
+ end
574
+
575
+ def build_lm(model_id)
576
+ @lm_cache ||= {}
577
+ return @lm_cache[model_id] if @lm_cache.key?(model_id)
578
+
579
+ provider = model_id.split('/', 2).first
580
+ api_key = api_key_for(provider)
581
+ unless api_key && !api_key.strip.empty?
582
+ DSPy.logger&.warn(
583
+ "DeepResearch skipped LM assignment due to missing API key",
584
+ model: model_id,
585
+ provider: provider
586
+ )
587
+ return nil
588
+ end
589
+
590
+ @lm_cache[model_id] = DSPy::LM.new(model_id, api_key: api_key)
591
+ rescue StandardError => e
592
+ DSPy.logger&.warn(
593
+ "DeepResearch failed to initialize LM",
594
+ model: model_id,
595
+ error: e.message
596
+ )
597
+ nil
598
+ end
599
+
600
+ def api_key_for(provider)
601
+ case provider
602
+ when 'openai'
603
+ ENV['OPENAI_API_KEY']
604
+ when 'anthropic'
605
+ ENV['ANTHROPIC_API_KEY']
606
+ when 'gemini'
607
+ ENV['GEMINI_API_KEY']
608
+ when 'google'
609
+ ENV['GEMINI_API_KEY'] || ENV['GOOGLE_API_KEY']
610
+ else
611
+ nil
612
+ end
613
+ end
614
+ end
615
+ end
616
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepResearch
5
+ class SectionQueue
6
+ extend T::Sig
7
+
8
+ SectionSpec = DSPy::DeepResearch::Signatures::BuildOutline::SectionSpec
9
+
10
+ sig { void }
11
+ def initialize
12
+ @queue = T.let([], T::Array[SectionSpec])
13
+ @attempts = T.let(Hash.new(0), T::Hash[String, Integer])
14
+ end
15
+
16
+ sig { params(section: SectionSpec).returns(SectionSpec) }
17
+ def enqueue(section)
18
+ base = base_identifier(section)
19
+ @attempts[base] = section.attempt
20
+ @queue << section
21
+ section
22
+ end
23
+
24
+ sig { params(section: SectionSpec).returns(SectionSpec) }
25
+ def enqueue_front(section)
26
+ base = base_identifier(section)
27
+ @attempts[base] = section.attempt
28
+ @queue.unshift(section)
29
+ section
30
+ end
31
+
32
+ sig { params(section: SectionSpec, prompt: String).returns(SectionSpec) }
33
+ def enqueue_follow_up(section, prompt:)
34
+ base = base_identifier(section)
35
+ next_attempt = section.attempt + 1
36
+ @queue.delete_if { |queued| base_identifier(queued) == base }
37
+
38
+ follow_up = SectionSpec.new(
39
+ identifier: "#{base}-retry-#{next_attempt}",
40
+ title: section.title,
41
+ prompt: prompt,
42
+ token_budget: section.token_budget,
43
+ attempt: next_attempt,
44
+ parent_identifier: section.parent_identifier || base
45
+ )
46
+
47
+ enqueue_front(follow_up)
48
+ end
49
+
50
+ sig { returns(T.nilable(SectionSpec)) }
51
+ def dequeue
52
+ @queue.shift
53
+ end
54
+
55
+ sig { returns(T::Boolean) }
56
+ def empty?
57
+ @queue.empty?
58
+ end
59
+
60
+ sig { params(section: SectionSpec).returns(Integer) }
61
+ def attempts_for(section)
62
+ base = base_identifier(section)
63
+ @attempts.fetch(base, section.attempt)
64
+ end
65
+
66
+ sig { void }
67
+ def clear
68
+ @queue.clear
69
+ end
70
+
71
+ private
72
+
73
+ sig { params(section: SectionSpec).returns(String) }
74
+ def base_identifier(section)
75
+ section.parent_identifier || section.identifier.split("-retry-").first
76
+ end
77
+ end
78
+ end
79
+ end