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,463 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepSearch
5
+ class Module < DSPy::Module
6
+ extend T::Sig
7
+
8
+ class Result < T::Struct
9
+ const :answer, String
10
+ const :notes, T::Array[String]
11
+ const :citations, T::Array[String]
12
+ const :budget_exhausted, T::Boolean, default: false
13
+ const :warning, T.nilable(String), default: nil
14
+ end
15
+
16
+ class TokenBudgetExceeded < DSPy::Error; end
17
+
18
+ DEFAULT_SEARCH_RESULTS = 5
19
+
20
+ MODEL_ENV_KEYS = {
21
+ seed: 'DSPY_DEEP_SEARCH_SEED_MODEL',
22
+ reader: 'DSPY_DEEP_SEARCH_READER_MODEL',
23
+ reason: 'DSPY_DEEP_SEARCH_REASON_MODEL'
24
+ }.freeze
25
+
26
+ MODEL_PRIORITY = {
27
+ seed: [
28
+ 'gemini/gemini-2.5-flash-lite',
29
+ 'anthropic/claude-haiku-4-5'
30
+ ],
31
+ reader: [
32
+ 'anthropic/claude-sonnet-4-5',
33
+ 'openai/gpt-4.1'
34
+ ],
35
+ reason: [
36
+ 'gemini/gemini-2.5-pro',
37
+ 'openai/o4-mini',
38
+ 'anthropic/claude-4.1-opus'
39
+ ]
40
+ }.freeze
41
+
42
+ subscribe 'lm.tokens', :meter_tokens
43
+
44
+ sig do
45
+ params(
46
+ token_budget: DSPy::DeepSearch::TokenBudget,
47
+ seed_predictor: T.untyped,
48
+ search_predictor: T.nilable(T.untyped),
49
+ reader_predictor: T.untyped,
50
+ reason_predictor: T.untyped,
51
+ search_client: DSPy::DeepSearch::Clients::ExaClient
52
+ ).void
53
+ end
54
+ def initialize(
55
+ token_budget: DSPy::DeepSearch::TokenBudget.new(limit: 20_000),
56
+ seed_predictor: DSPy::Predict.new(DSPy::DeepSearch::Signatures::SeedQuery),
57
+ search_predictor: nil,
58
+ reader_predictor: DSPy::Predict.new(DSPy::DeepSearch::Signatures::ReadSource),
59
+ reason_predictor: DSPy::Predict.new(DSPy::DeepSearch::Signatures::ReasonStep),
60
+ search_client: DSPy::DeepSearch::Clients::ExaClient.new
61
+ )
62
+ super()
63
+
64
+ @token_budget = token_budget
65
+ @token_budget_limit = token_budget.limit
66
+ @seed_predictor = seed_predictor
67
+ @search_predictor = search_predictor
68
+ @reader_predictor = reader_predictor
69
+ @reason_predictor = reason_predictor
70
+ @search_client = search_client
71
+ @gap_queue = DSPy::DeepSearch::GapQueue.new
72
+
73
+ configure_default_predictor_models
74
+ end
75
+
76
+ def forward_untyped(**input_values)
77
+ question = input_values[:question]
78
+ unless question.is_a?(String)
79
+ raise ArgumentError, "DeepSearch expects keyword argument :question"
80
+ end
81
+
82
+ reset_state!
83
+ process_question(question)
84
+ rescue DSPy::DeepSearch::TokenBudget::Exceeded => e
85
+ build_budget_exhausted_result(question, e)
86
+ end
87
+
88
+ sig { override.returns(T::Array[[String, DSPy::Module]]) }
89
+ def named_predictors
90
+ pairs = []
91
+ pairs << ["seed_predictor", @seed_predictor] if @seed_predictor
92
+ pairs << ["search_predictor", T.must(@search_predictor)] if @search_predictor
93
+ pairs << ["reader_predictor", @reader_predictor] if @reader_predictor
94
+ pairs << ["reason_predictor", @reason_predictor] if @reason_predictor
95
+ pairs
96
+ end
97
+
98
+ sig { override.returns(T::Array[DSPy::Module]) }
99
+ def predictors
100
+ named_predictors.map { |(_, predictor)| predictor }
101
+ end
102
+
103
+ sig { params(instruction: String).returns(Module) }
104
+ def with_instruction(instruction)
105
+ clone_with(
106
+ seed_predictor: apply_instruction(@seed_predictor, instruction),
107
+ search_predictor: apply_instruction(@search_predictor, instruction),
108
+ reader_predictor: apply_instruction(@reader_predictor, instruction),
109
+ reason_predictor: apply_instruction(@reason_predictor, instruction),
110
+ token_budget_limit: @token_budget_limit
111
+ )
112
+ end
113
+
114
+ sig { params(examples: T::Array[DSPy::FewShotExample]).returns(Module) }
115
+ def with_examples(examples)
116
+ examples_copy = examples.map { |example| example }
117
+ clone_with(
118
+ seed_predictor: apply_examples(@seed_predictor, examples_copy),
119
+ search_predictor: apply_examples(@search_predictor, examples_copy),
120
+ reader_predictor: apply_examples(@reader_predictor, examples_copy),
121
+ reason_predictor: apply_examples(@reason_predictor, examples_copy),
122
+ token_budget_limit: @token_budget_limit
123
+ )
124
+ end
125
+ sig { params(limit: Integer).returns(Module) }
126
+ def with_token_budget(limit)
127
+ clone_with(
128
+ seed_predictor: @seed_predictor,
129
+ search_predictor: @search_predictor,
130
+ reader_predictor: @reader_predictor,
131
+ reason_predictor: @reason_predictor,
132
+ token_budget_limit: limit
133
+ )
134
+ end
135
+
136
+ private
137
+
138
+ sig { params(question: String).returns(Result) }
139
+ def process_question(question)
140
+ query = @seed_predictor.call(question: question).query
141
+ loop do
142
+ emit_loop_started(question, query)
143
+
144
+ urls = fetch_search_urls(query)
145
+ break if urls.empty?
146
+
147
+ urls.each { |url| enqueue_url(url) }
148
+ collect_notes
149
+
150
+ decision = @reason_predictor.call(question: question, insights: @notes)
151
+ emit_reason_decision(question, decision)
152
+
153
+ case decision.decision
154
+ when DSPy::DeepSearch::Signatures::ReasonStep::Decision::Answer
155
+ answer_text = decision.draft_answer || synthesize_answer
156
+ return Result.new(answer: answer_text, notes: @notes.dup, citations: @citations.dup)
157
+ when DSPy::DeepSearch::Signatures::ReasonStep::Decision::ContinueSearch
158
+ query = decision.refined_query || query
159
+ next
160
+ when DSPy::DeepSearch::Signatures::ReasonStep::Decision::ReadMore
161
+ collect_notes if pending_urls?
162
+ next
163
+ end
164
+ end
165
+
166
+ Result.new(answer: synthesize_answer, notes: @notes.dup, citations: @citations.dup)
167
+ end
168
+
169
+ sig { params(url: String).void }
170
+ def enqueue_url(url)
171
+ @gap_queue.enqueue(url)
172
+ end
173
+
174
+ sig { returns(T::Boolean) }
175
+ def pending_urls?
176
+ !@gap_queue.empty?
177
+ end
178
+
179
+ sig { void }
180
+ def collect_notes
181
+ until @gap_queue.empty?
182
+ url = @gap_queue.dequeue
183
+ fetch_and_extract(url)
184
+ end
185
+ end
186
+
187
+ sig { params(url: String).void }
188
+ def fetch_and_extract(url)
189
+ DSPy.event(
190
+ "deep_search.fetch.started",
191
+ url: url
192
+ )
193
+
194
+ notes_before = @notes.length
195
+ citations_before = @citations.length
196
+
197
+ contents = @search_client.contents(urls: [url])
198
+ record_notes(url, contents)
199
+ reader_notes = @reader_predictor.call(url: url).notes
200
+ @notes.concat(Array(reader_notes))
201
+
202
+ DSPy.event(
203
+ "deep_search.fetch.completed",
204
+ url: url,
205
+ notes_added: @notes.length - notes_before,
206
+ citations_added: @citations.length - citations_before,
207
+ token_budget_remaining: token_budget_remaining
208
+ )
209
+ rescue DSPy::DeepSearch::Clients::ExaClient::ApiError => e
210
+ DSPy.event(
211
+ "deep_search.fetch.failed",
212
+ url: url,
213
+ error: e.message
214
+ )
215
+ DSPy.logger&.warn("DeepSearch fetch failed", url: url, error: e.message)
216
+ end
217
+
218
+ sig { params(url: String, contents: T::Array[DSPy::DeepSearch::Clients::ExaClient::Content]).void }
219
+ def record_notes(url, contents)
220
+ contents.each do |content|
221
+ if content.summary
222
+ @notes << content.summary
223
+ end
224
+ content.highlights.each do |highlight|
225
+ @notes << highlight
226
+ end
227
+ @citations << url unless @citations.include?(url)
228
+ end
229
+ end
230
+
231
+ sig { returns(String) }
232
+ def synthesize_answer
233
+ return "" if @notes.empty?
234
+
235
+ @notes.first(5).join("\n")
236
+ end
237
+
238
+ sig { void }
239
+ def reset_state!
240
+ @notes = []
241
+ @citations = []
242
+ @gap_queue = DSPy::DeepSearch::GapQueue.new
243
+ end
244
+
245
+ sig { params(query: String).returns(T::Array[String]) }
246
+ def fetch_search_urls(query)
247
+ if @search_predictor
248
+ Array(@search_predictor.call(query: query).urls).compact
249
+ else
250
+ results = Array(
251
+ @search_client.search(
252
+ query: query,
253
+ num_results: DEFAULT_SEARCH_RESULTS,
254
+ autoprompt: true
255
+ )
256
+ )
257
+ results.map do |result|
258
+ if result.respond_to?(:url)
259
+ result.url
260
+ elsif result.is_a?(Hash)
261
+ result[:url] || result['url']
262
+ else
263
+ nil
264
+ end
265
+ end.compact.uniq
266
+ end
267
+ end
268
+
269
+ sig { params(_event_name: String, attrs: T::Hash[Symbol, T.untyped]).void }
270
+ def meter_tokens(_event_name, attrs)
271
+ @token_budget.track!(
272
+ prompt_tokens: attrs[:input_tokens].to_i,
273
+ completion_tokens: attrs[:output_tokens].to_i
274
+ )
275
+ end
276
+
277
+ sig do
278
+ params(
279
+ seed_predictor: T.untyped,
280
+ search_predictor: T.nilable(T.untyped),
281
+ reader_predictor: T.untyped,
282
+ reason_predictor: T.untyped,
283
+ token_budget_limit: Integer
284
+ ).returns(Module)
285
+ end
286
+ def clone_with(seed_predictor:, search_predictor:, reader_predictor:, reason_predictor:, token_budget_limit: @token_budget_limit)
287
+ self.class.new(
288
+ token_budget: DSPy::DeepSearch::TokenBudget.new(limit: token_budget_limit),
289
+ seed_predictor: seed_predictor,
290
+ search_predictor: search_predictor,
291
+ reader_predictor: reader_predictor,
292
+ reason_predictor: reason_predictor,
293
+ search_client: @search_client
294
+ )
295
+ end
296
+
297
+ sig { params(predictor: T.nilable(T.untyped), instruction: String).returns(T.nilable(T.untyped)) }
298
+ def apply_instruction(predictor, instruction)
299
+ return nil if predictor.nil?
300
+ return predictor.with_instruction(instruction) if predictor.respond_to?(:with_instruction)
301
+ predictor
302
+ end
303
+
304
+ sig { params(predictor: T.nilable(T.untyped), examples: T::Array[DSPy::FewShotExample]).returns(T.nilable(T.untyped)) }
305
+ def apply_examples(predictor, examples)
306
+ return nil if predictor.nil?
307
+ return predictor.with_examples(examples) if predictor.respond_to?(:with_examples)
308
+ predictor
309
+ end
310
+
311
+ sig { params(question: String, query: String).void }
312
+ def emit_loop_started(question, query)
313
+ DSPy.event(
314
+ "deep_search.loop.started",
315
+ question: question,
316
+ query: query,
317
+ token_budget_remaining: token_budget_remaining
318
+ )
319
+ end
320
+
321
+ sig { params(question: String, decision: T.untyped).void }
322
+ def emit_reason_decision(question, decision)
323
+ decision_enum = decision.respond_to?(:decision) ? decision.decision : nil
324
+ serialized_decision =
325
+ if decision_enum.respond_to?(:serialize)
326
+ decision_enum.serialize
327
+ elsif decision_enum.respond_to?(:to_s)
328
+ decision_enum.to_s
329
+ else
330
+ nil
331
+ end
332
+
333
+ DSPy.event(
334
+ "deep_search.reason.decision",
335
+ question: question,
336
+ decision: serialized_decision,
337
+ notes_count: @notes.length,
338
+ citations_count: @citations.length,
339
+ refined_query: decision.respond_to?(:refined_query) ? decision.refined_query : nil,
340
+ draft_answer: decision.respond_to?(:draft_answer) ? decision.draft_answer : nil,
341
+ token_budget_remaining: token_budget_remaining
342
+ )
343
+ end
344
+
345
+ sig { returns(Integer) }
346
+ def token_budget_remaining
347
+ remaining = @token_budget_limit - @token_budget.total_tokens
348
+ remaining.negative? ? 0 : remaining
349
+ end
350
+
351
+ def configure_default_predictor_models
352
+ @lm_cache = {}
353
+ assign_model(@seed_predictor, :seed)
354
+ assign_model(@reader_predictor, :reader)
355
+ assign_model(@reason_predictor, :reason)
356
+ end
357
+
358
+ def env_model(role)
359
+ key = MODEL_ENV_KEYS[role]
360
+ value = key ? ENV[key] : nil
361
+ return nil if value.nil?
362
+
363
+ trimmed = value.strip
364
+ trimmed.empty? ? nil : trimmed
365
+ end
366
+
367
+ def assign_model(predictor, role)
368
+ return unless predictor
369
+ return if predictor.respond_to?(:config) && predictor.config.lm
370
+
371
+ candidates = []
372
+ env_override = env_model(role)
373
+ candidates << env_override if env_override
374
+ candidates.concat(Array(MODEL_PRIORITY[role]))
375
+
376
+ candidates.each do |model_id|
377
+ next unless model_id
378
+ lm = build_lm(model_id)
379
+ next unless lm
380
+
381
+ begin
382
+ predictor.configure { |config| config.lm = lm }
383
+ return
384
+ rescue StandardError => e
385
+ DSPy.logger&.warn(
386
+ "DeepSearch predictor LM assignment error",
387
+ role: role,
388
+ model: model_id,
389
+ error: e.message
390
+ )
391
+ end
392
+ end
393
+
394
+ DSPy.logger&.warn(
395
+ "DeepSearch predictor LM assignment skipped (no viable model)",
396
+ role: role
397
+ )
398
+ end
399
+
400
+ def build_lm(model_id)
401
+ @lm_cache ||= {}
402
+ return @lm_cache[model_id] if @lm_cache.key?(model_id)
403
+
404
+ provider = model_id.split('/', 2).first
405
+ api_key = api_key_for(provider)
406
+ unless api_key && !api_key.strip.empty?
407
+ DSPy.logger&.warn(
408
+ "DeepSearch skipped LM assignment due to missing API key",
409
+ model: model_id,
410
+ provider: provider
411
+ )
412
+ return nil
413
+ end
414
+
415
+ @lm_cache[model_id] = DSPy::LM.new(model_id, api_key: api_key)
416
+ rescue StandardError => e
417
+ DSPy.logger&.warn(
418
+ "DeepSearch failed to initialize LM",
419
+ model: model_id,
420
+ error: e.message
421
+ )
422
+ nil
423
+ end
424
+
425
+ def api_key_for(provider)
426
+ case provider
427
+ when 'openai'
428
+ ENV['OPENAI_API_KEY']
429
+ when 'anthropic'
430
+ ENV['ANTHROPIC_API_KEY']
431
+ when 'gemini'
432
+ ENV['GEMINI_API_KEY']
433
+ when 'google'
434
+ ENV['GEMINI_API_KEY'] || ENV['GOOGLE_API_KEY']
435
+ else
436
+ nil
437
+ end
438
+ end
439
+
440
+ sig { params(question: String, error: DSPy::DeepSearch::TokenBudget::Exceeded).returns(Result) }
441
+ def build_budget_exhausted_result(question, error)
442
+ warning = error.message
443
+ DSPy.event(
444
+ "deep_search.budget.exhausted",
445
+ question: question,
446
+ notes_count: @notes.length,
447
+ citations_count: @citations.length,
448
+ token_budget_limit: @token_budget_limit,
449
+ total_tokens: @token_budget.total_tokens,
450
+ warning: warning
451
+ )
452
+
453
+ Result.new(
454
+ answer: synthesize_answer,
455
+ notes: @notes.dup,
456
+ citations: @citations.dup,
457
+ budget_exhausted: true,
458
+ warning: warning
459
+ )
460
+ end
461
+ end
462
+ end
463
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepSearch
5
+ module Signatures
6
+ extend T::Sig
7
+
8
+ class SeedQuery < DSPy::Signature
9
+ description "Seed the first search query for DeepSearch"
10
+
11
+ input do
12
+ const :question, String, description: "User research question"
13
+ end
14
+
15
+ output do
16
+ const :query, String, description: "Initial search query"
17
+ end
18
+ end
19
+
20
+ class SearchSources < DSPy::Signature
21
+ description "Call the search provider and return candidate URLs"
22
+
23
+ input do
24
+ const :query, String, description: "Search engine query"
25
+ end
26
+
27
+ output do
28
+ const :urls, T::Array[String], description: "Ranked URLs to read next"
29
+ end
30
+ end
31
+
32
+ class ReadSource < DSPy::Signature
33
+ description "Summarize a single source into bullet notes"
34
+
35
+ input do
36
+ const :url, String, description: "URL selected by the search step"
37
+ end
38
+
39
+ output do
40
+ const :notes, T::Array[String], description: "Key takeaways from the page"
41
+ end
42
+ end
43
+
44
+ class ReasonStep < DSPy::Signature
45
+ description "Decide whether to keep searching, read more, or answer"
46
+
47
+ class Decision < T::Enum
48
+ enums do
49
+ ContinueSearch = new("continue_search")
50
+ ReadMore = new("read_more")
51
+ Answer = new("answer")
52
+ end
53
+ end
54
+
55
+ input do
56
+ const :question, String, description: "Original user question"
57
+ const :insights, T::Array[String], description: "Accumulated notes"
58
+ end
59
+
60
+ output do
61
+ const :decision, Decision, description: "Next action for the loop"
62
+ const :refined_query, T.nilable(String), description: "Follow-up search query"
63
+ const :draft_answer, T.nilable(String), description: "Candidate answer"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepSearch
5
+ class TokenBudget
6
+ extend T::Sig
7
+
8
+ class Exceeded < StandardError; end
9
+
10
+ sig { returns(Integer) }
11
+ attr_reader :limit
12
+
13
+ sig { returns(Integer) }
14
+ attr_reader :total_tokens
15
+
16
+ sig { params(limit: Integer).void }
17
+ def initialize(limit:)
18
+ @limit = limit
19
+ @total_tokens = T.let(0, Integer)
20
+ end
21
+
22
+ sig do
23
+ params(
24
+ prompt_tokens: Integer,
25
+ completion_tokens: Integer
26
+ ).void
27
+ end
28
+ def track!(prompt_tokens:, completion_tokens:)
29
+ prompt = T.must(prompt_tokens)
30
+ completion = T.must(completion_tokens)
31
+
32
+ increment = prompt + completion
33
+ new_total = @total_tokens + increment
34
+
35
+ if new_total >= limit
36
+ raise Exceeded, "Token budget exceeded: #{new_total}/#{limit}"
37
+ end
38
+
39
+ @total_tokens = new_total
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module DeepSearch
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dspy-deep_research
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Vicente Reig Rincón de Arellano
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dspy
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.30'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 0.30.1
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '0.30'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.30.1
32
+ - !ruby/object:Gem::Dependency
33
+ name: dspy-deep_search
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - '='
37
+ - !ruby/object:Gem::Version
38
+ version: 1.0.0
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - '='
44
+ - !ruby/object:Gem::Version
45
+ version: 1.0.0
46
+ - !ruby/object:Gem::Dependency
47
+ name: sorbet-runtime
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '0.5'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.5'
60
+ description: Planner, queue, and coherence orchestration built on DSPy::DeepSearch.
61
+ email:
62
+ - oss@vicente.services
63
+ executables: []
64
+ extensions: []
65
+ extra_rdoc_files: []
66
+ files:
67
+ - LICENSE
68
+ - README.md
69
+ - lib/dspy/deep_research.rb
70
+ - lib/dspy/deep_research/README.md
71
+ - lib/dspy/deep_research/errors.rb
72
+ - lib/dspy/deep_research/module.rb
73
+ - lib/dspy/deep_research/section_queue.rb
74
+ - lib/dspy/deep_research/signatures.rb
75
+ - lib/dspy/deep_research/version.rb
76
+ - lib/dspy/deep_search/clients/exa_client.rb
77
+ - lib/dspy/deep_search/gap_queue.rb
78
+ - lib/dspy/deep_search/module.rb
79
+ - lib/dspy/deep_search/signatures.rb
80
+ - lib/dspy/deep_search/token_budget.rb
81
+ - lib/dspy/deep_search/version.rb
82
+ homepage: https://vicentereig.github.io/dspy.rb/
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://vicentereig.github.io/dspy.rb/
87
+ source_code_uri: https://github.com/vicentereig/dspy.rb
88
+ changelog_uri: https://github.com/vicentereig/dspy.rb/blob/main/CHANGELOG.md
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '3.1'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.6.9
104
+ specification_version: 4
105
+ summary: DeepResearch orchestration for DSPy
106
+ test_files: []