dspy 0.34.1 → 0.34.3

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +139 -216
  3. data/lib/dspy/chain_of_thought.rb +3 -2
  4. data/lib/dspy/context.rb +57 -30
  5. data/lib/dspy/evals/version.rb +1 -1
  6. data/lib/dspy/evals.rb +42 -31
  7. data/lib/dspy/events.rb +2 -3
  8. data/lib/dspy/example.rb +1 -1
  9. data/lib/dspy/lm/adapter.rb +39 -0
  10. data/lib/dspy/lm/json_strategy.rb +37 -2
  11. data/lib/dspy/lm/message.rb +1 -1
  12. data/lib/dspy/lm/response.rb +1 -1
  13. data/lib/dspy/lm/usage.rb +4 -4
  14. data/lib/dspy/lm.rb +27 -79
  15. data/lib/dspy/mixins/type_coercion.rb +189 -30
  16. data/lib/dspy/module.rb +70 -25
  17. data/lib/dspy/predict.rb +32 -5
  18. data/lib/dspy/prediction.rb +15 -57
  19. data/lib/dspy/prompt.rb +50 -30
  20. data/lib/dspy/propose/dataset_summary_generator.rb +1 -1
  21. data/lib/dspy/propose/grounded_proposer.rb +3 -3
  22. data/lib/dspy/re_act.rb +0 -162
  23. data/lib/dspy/registry/signature_registry.rb +3 -3
  24. data/lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb +1 -27
  25. data/lib/dspy/schema/sorbet_json_schema.rb +7 -6
  26. data/lib/dspy/schema/version.rb +1 -1
  27. data/lib/dspy/schema_adapters.rb +1 -1
  28. data/lib/dspy/storage/program_storage.rb +2 -2
  29. data/lib/dspy/structured_outputs_prompt.rb +3 -3
  30. data/lib/dspy/teleprompt/utils.rb +2 -2
  31. data/lib/dspy/tools/github_cli_toolset.rb +7 -7
  32. data/lib/dspy/tools/text_processing_toolset.rb +2 -2
  33. data/lib/dspy/tools/toolset.rb +1 -1
  34. data/lib/dspy/version.rb +1 -1
  35. data/lib/dspy.rb +1 -4
  36. metadata +1 -26
  37. data/lib/dspy/events/subscriber_mixin.rb +0 -79
  38. data/lib/dspy/events/subscribers.rb +0 -43
  39. data/lib/dspy/memory/embedding_engine.rb +0 -68
  40. data/lib/dspy/memory/in_memory_store.rb +0 -216
  41. data/lib/dspy/memory/local_embedding_engine.rb +0 -244
  42. data/lib/dspy/memory/memory_compactor.rb +0 -298
  43. data/lib/dspy/memory/memory_manager.rb +0 -266
  44. data/lib/dspy/memory/memory_record.rb +0 -163
  45. data/lib/dspy/memory/memory_store.rb +0 -90
  46. data/lib/dspy/memory.rb +0 -30
  47. data/lib/dspy/tools/memory_toolset.rb +0 -117
data/lib/dspy/evals.rb CHANGED
@@ -254,25 +254,7 @@ module DSPy
254
254
  # Evaluate program on a single example
255
255
  sig { params(example: T.untyped, trace: T.nilable(T.untyped)).returns(EvaluationResult) }
256
256
  def call(example, trace: nil)
257
- run_callbacks(:before, :call, example: example)
258
-
259
- DSPy::Context.with_span(
260
- operation: 'evaluation.example',
261
- 'dspy.module' => 'Evaluator',
262
- 'evaluation.program' => @program.class.name,
263
- 'evaluation.has_metric' => !@metric.nil?
264
- ) do
265
- begin
266
- perform_call(example, trace: trace)
267
- rescue => e
268
- build_error_result(example, e, trace: trace)
269
- end
270
- end.then do |result|
271
- @last_example_result = result
272
- emit_example_observation(example, result)
273
- run_callbacks(:after, :call, example: example, result: result)
274
- result
275
- end
257
+ call_with_program(@program, example, trace: trace, track_state: true)
276
258
  end
277
259
 
278
260
  # Evaluate program on multiple examples
@@ -403,8 +385,9 @@ module DSPy
403
385
 
404
386
  futures = batch.map do |item|
405
387
  Concurrent::Promises.future_on(executor) do
406
- [:ok, item[:index], safe_call(item[:example])]
407
- rescue => e
388
+ program_for_thread = fork_program_for_thread
389
+ [:ok, item[:index], safe_call(item[:example], program: program_for_thread, track_state: false)]
390
+ rescue StandardError => e
408
391
  [:error, item[:index], e]
409
392
  end
410
393
  end
@@ -441,18 +424,18 @@ module DSPy
441
424
  results.compact
442
425
  end
443
426
 
444
- def safe_call(example)
445
- call(example)
446
- rescue => e
427
+ def safe_call(example, program: @program, track_state: true)
428
+ call_with_program(program, example, track_state: track_state)
429
+ rescue StandardError => e
447
430
  build_error_result(example, e)
448
431
  end
449
432
 
450
- def perform_call(example, trace:)
433
+ def perform_call(example, trace:, program:)
451
434
  # Extract input from example - support both hash and object formats
452
435
  input_values = extract_input_values(example)
453
436
 
454
437
  # Run prediction
455
- prediction = @program.call(**input_values)
438
+ prediction = program.call(**input_values)
456
439
 
457
440
  # Calculate metrics if provided
458
441
  metrics = {}
@@ -469,7 +452,7 @@ module DSPy
469
452
  passed = !!metric_result
470
453
  metrics[:passed] = passed
471
454
  end
472
- rescue => e
455
+ rescue StandardError => e
473
456
  passed = false
474
457
  metrics[:error] = e.message
475
458
  metrics[:passed] = false
@@ -490,6 +473,34 @@ module DSPy
490
473
  )
491
474
  end
492
475
 
476
+ def call_with_program(program, example, trace: nil, track_state: true)
477
+ run_callbacks(:before, :call, example: example)
478
+
479
+ DSPy::Context.with_span(
480
+ operation: 'evaluation.example',
481
+ 'dspy.module' => 'Evaluator',
482
+ 'evaluation.program' => program.class.name,
483
+ 'evaluation.has_metric' => !@metric.nil?
484
+ ) do
485
+ begin
486
+ perform_call(example, trace: trace, program: program)
487
+ rescue StandardError => e
488
+ build_error_result(example, e, trace: trace)
489
+ end
490
+ end.then do |result|
491
+ @last_example_result = result if track_state
492
+ emit_example_observation(example, result)
493
+ run_callbacks(:after, :call, example: example, result: result)
494
+ result
495
+ end
496
+ end
497
+
498
+ def fork_program_for_thread
499
+ return @program if @program.nil?
500
+ return @program.dup_for_thread if @program.respond_to?(:dup_for_thread)
501
+ @program.dup
502
+ end
503
+
493
504
  def build_error_result(example, error, trace: nil)
494
505
  metrics = {
495
506
  error: error.message,
@@ -680,7 +691,7 @@ module DSPy
680
691
  if @export_scores
681
692
  export_example_score(example, result)
682
693
  end
683
- rescue => e
694
+ rescue StandardError => e
684
695
  DSPy.log('evals.example.observation_error', error: e.message)
685
696
  end
686
697
 
@@ -698,7 +709,7 @@ module DSPy
698
709
  if @export_scores
699
710
  export_batch_score(batch_result)
700
711
  end
701
- rescue => e
712
+ rescue StandardError => e
702
713
  DSPy.log('evals.batch.observation_error', error: e.message)
703
714
  end
704
715
 
@@ -711,7 +722,7 @@ module DSPy
711
722
  score_value,
712
723
  comment: "Example: #{example_id || 'unknown'}, passed: #{result.passed}"
713
724
  )
714
- rescue => e
725
+ rescue StandardError => e
715
726
  DSPy.log('evals.score_export_error', error: e.message)
716
727
  end
717
728
 
@@ -721,7 +732,7 @@ module DSPy
721
732
  batch_result.pass_rate,
722
733
  comment: "Batch: #{batch_result.passed_examples}/#{batch_result.total_examples} passed"
723
734
  )
724
- rescue => e
735
+ rescue StandardError => e
725
736
  DSPy.log('evals.batch_score_export_error', error: e.message)
726
737
  end
727
738
 
data/lib/dspy/events.rb CHANGED
@@ -11,7 +11,6 @@ module DSPy
11
11
  class EventRegistry
12
12
  def initialize
13
13
  @listeners = {}
14
- @subscription_counter = 0
15
14
  @mutex = Mutex.new
16
15
  end
17
16
 
@@ -53,7 +52,7 @@ module DSPy
53
52
  matching_listeners.each do |id, listener|
54
53
  begin
55
54
  listener[:block].call(event_name, attributes)
56
- rescue => e
55
+ rescue StandardError => e
57
56
  # Log the error but continue processing other listeners
58
57
  # Use emit_log directly to avoid infinite recursion
59
58
  DSPy.send(:emit_log, 'event.listener.error', {
@@ -80,4 +79,4 @@ module DSPy
80
79
  end
81
80
  end
82
81
  end
83
- end
82
+ end
data/lib/dspy/example.rb CHANGED
@@ -178,7 +178,7 @@ module DSPy
178
178
  id: example_data[:id] || "example_#{index}"
179
179
  )
180
180
  examples << example
181
- rescue => e
181
+ rescue StandardError => e
182
182
  errors << "Example #{index}: #{e.message}"
183
183
  end
184
184
  end
@@ -57,6 +57,45 @@ module DSPy
57
57
  content.is_a?(Array) && content.any? { |item| item[:type] == 'image' }
58
58
  end
59
59
  end
60
+
61
+ # Format multimodal messages for a specific provider
62
+ # @param messages [Array<Hash>] Array of message hashes
63
+ # @param provider_name [String] Provider name for image validation and formatting
64
+ # @return [Array<Hash>] Messages with images formatted for the provider
65
+ def format_multimodal_messages(messages, provider_name)
66
+ messages.map do |msg|
67
+ if msg[:content].is_a?(Array)
68
+ formatted_content = msg[:content].map do |item|
69
+ case item[:type]
70
+ when 'text'
71
+ { type: 'text', text: item[:text] }
72
+ when 'image'
73
+ format_image_for_provider(item[:image], provider_name)
74
+ else
75
+ item
76
+ end
77
+ end
78
+ { role: msg[:role], content: formatted_content }
79
+ else
80
+ msg
81
+ end
82
+ end
83
+ end
84
+
85
+ # Format an image for a specific provider
86
+ # @param image [DSPy::Image] The image to format
87
+ # @param provider_name [String] Provider name (openai, anthropic, gemini, etc.)
88
+ # @return [Hash] Provider-specific image format
89
+ def format_image_for_provider(image, provider_name)
90
+ image.validate_for_provider!(provider_name)
91
+ format_method = "to_#{provider_name}_format"
92
+ if image.respond_to?(format_method)
93
+ image.send(format_method)
94
+ else
95
+ # For providers without specific format methods, return the item as-is
96
+ { type: 'image', image: image }
97
+ end
98
+ end
60
99
  end
61
100
  end
62
101
  end
@@ -136,19 +136,54 @@ module DSPy
136
136
  end
137
137
 
138
138
  # Convert signature to Anthropic tool schema
139
+ # Uses strict: true for constrained decoding (Anthropic structured outputs)
140
+ # Anthropic strict mode requires ALL properties in required at every level.
139
141
  sig { returns(T::Hash[Symbol, T.untyped]) }
140
142
  def convert_to_anthropic_tool_schema
141
143
  output_fields = signature_class.output_field_descriptors
142
144
 
143
- {
145
+ schema = {
144
146
  name: "json_output",
145
147
  description: "Output the result in the required JSON format",
148
+ strict: true,
146
149
  input_schema: {
147
150
  type: "object",
148
151
  properties: build_properties_from_fields(output_fields),
149
- required: output_fields.keys.map(&:to_s)
152
+ required: build_required_from_fields(output_fields),
153
+ additionalProperties: false
150
154
  }
151
155
  }
156
+
157
+ # Anthropic strict mode: ALL properties must be in required at every level.
158
+ # Non-required properties get auto-wrapped in null unions by the grammar compiler,
159
+ # which counts against the 16-union-parameter limit.
160
+ enforce_all_required(schema[:input_schema])
161
+
162
+ schema
163
+ end
164
+
165
+ # Build required field list, excluding fields that have defaults
166
+ sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
167
+ def build_required_from_fields(fields)
168
+ fields.reject { |_name, descriptor| descriptor.has_default }.keys.map(&:to_s)
169
+ end
170
+
171
+ # Recursively enforce that all properties are in required and
172
+ # additionalProperties is false, as required by Anthropic strict mode.
173
+ sig { params(schema: T::Hash[Symbol, T.untyped]).void }
174
+ def enforce_all_required(schema)
175
+ return unless schema.is_a?(Hash)
176
+
177
+ if schema[:type] == "object" && schema[:properties]
178
+ schema[:required] = schema[:properties].keys.map(&:to_s)
179
+ schema[:additionalProperties] = false
180
+ schema[:properties].each_value { |v| enforce_all_required(v) }
181
+ elsif schema[:type] == "array" && schema[:items]
182
+ enforce_all_required(schema[:items])
183
+ elsif schema[:type].is_a?(Array)
184
+ # type: ["array", "null"] — check items if present
185
+ enforce_all_required(schema[:items]) if schema[:items]
186
+ end
152
187
  end
153
188
 
154
189
  # Build JSON schema properties from output fields
@@ -154,7 +154,7 @@ module DSPy
154
154
  content: formatted_content,
155
155
  name: data[:name]&.to_s
156
156
  )
157
- rescue => e
157
+ rescue StandardError => e
158
158
  DSPy.logger.debug("Failed to create Message: #{e.message}")
159
159
  nil
160
160
  end
@@ -182,7 +182,7 @@ module DSPy
182
182
  else
183
183
  ResponseMetadata.new(**common_fields)
184
184
  end
185
- rescue => e
185
+ rescue StandardError => e
186
186
  DSPy.logger.debug("Failed to create response metadata: #{e.message}")
187
187
  # Fallback to basic metadata
188
188
  ResponseMetadata.new(
data/lib/dspy/lm/usage.rb CHANGED
@@ -99,7 +99,7 @@ module DSPy
99
99
  prompt_tokens_details: prompt_details,
100
100
  completion_tokens_details: completion_details
101
101
  )
102
- rescue => e
102
+ rescue StandardError => e
103
103
  DSPy.logger.debug("Failed to create OpenAI usage: #{e.message}")
104
104
  nil
105
105
  end
@@ -133,7 +133,7 @@ module DSPy
133
133
  output_tokens: output_tokens,
134
134
  total_tokens: total_tokens
135
135
  )
136
- rescue => e
136
+ rescue StandardError => e
137
137
  DSPy.logger.debug("Failed to create Anthropic usage: #{e.message}")
138
138
  nil
139
139
  end
@@ -150,7 +150,7 @@ module DSPy
150
150
  output_tokens: output_tokens,
151
151
  total_tokens: total_tokens
152
152
  )
153
- rescue => e
153
+ rescue StandardError => e
154
154
  DSPy.logger.debug("Failed to create Gemini usage: #{e.message}")
155
155
  nil
156
156
  end
@@ -167,7 +167,7 @@ module DSPy
167
167
  output_tokens: output_tokens,
168
168
  total_tokens: total_tokens
169
169
  )
170
- rescue => e
170
+ rescue StandardError => e
171
171
  DSPy.logger.debug("Failed to create generic usage: #{e.message}")
172
172
  nil
173
173
  end
data/lib/dspy/lm.rb CHANGED
@@ -42,15 +42,11 @@ module DSPy
42
42
 
43
43
  def chat(inference_module, input_values, &block)
44
44
  # Capture the current DSPy context before entering Sync block
45
- parent_context = DSPy::Context.current.dup
45
+ parent_context = DSPy::Context.current
46
46
 
47
47
  Sync do
48
- # Properly restore the context in the new fiber created by Sync
49
- # We need to set both thread and fiber storage for the new context system
50
- thread_key = :"dspy_context_#{Thread.current.object_id}"
51
- Thread.current[thread_key] = parent_context
52
- Thread.current[:dspy_context] = parent_context # Keep for backward compatibility
53
- Fiber[:dspy_context] = parent_context
48
+ # Isolate fiber context while preserving trace/module ancestry
49
+ Fiber[:dspy_context] = DSPy::Context.fork_context(parent_context)
54
50
 
55
51
  signature_class = inference_module.signature_class
56
52
 
@@ -136,29 +132,6 @@ module DSPy
136
132
  response
137
133
  end
138
134
 
139
- # Determines if LM-level events should be emitted using smart consolidation
140
- def should_emit_lm_events?
141
- # Emit LM events only if we're not in a nested context (smart consolidation)
142
- !is_nested_context?
143
- end
144
-
145
- # Determines if we're in a nested context where higher-level events are being emitted
146
- def is_nested_context?
147
- caller_locations = caller_locations(1, 30)
148
- return false if caller_locations.nil?
149
-
150
- # Look for higher-level DSPy modules in the call stack
151
- # We consider ChainOfThought and ReAct as higher-level modules
152
- higher_level_modules = caller_locations.select do |loc|
153
- loc.path.include?('chain_of_thought') ||
154
- loc.path.include?('re_act') ||
155
- loc.path.include?('react')
156
- end
157
-
158
- # If we have higher-level modules in the call stack, we're in a nested context
159
- higher_level_modules.any?
160
- end
161
-
162
135
  def parse_model_id(model_id)
163
136
  unless model_id.include?('/')
164
137
  raise ArgumentError, "model_id must include provider (e.g., 'openai/gpt-4', 'anthropic/claude-3'). Legacy format without provider is no longer supported."
@@ -173,7 +146,7 @@ module DSPy
173
146
 
174
147
  # Determine if structured outputs will be used and wrap prompt if so
175
148
  base_prompt = inference_module.prompt
176
- prompt = if will_use_structured_outputs?(inference_module.signature_class)
149
+ prompt = if will_use_structured_outputs?(inference_module.signature_class, data_format: base_prompt.data_format)
177
150
  StructuredOutputsPrompt.new(**base_prompt.to_h)
178
151
  else
179
152
  base_prompt
@@ -198,8 +171,9 @@ module DSPy
198
171
  messages
199
172
  end
200
173
 
201
- def will_use_structured_outputs?(signature_class)
174
+ def will_use_structured_outputs?(signature_class, data_format: nil)
202
175
  return false unless signature_class
176
+ return false if data_format == :toon
203
177
 
204
178
  adapter_class_name = adapter.class.name
205
179
 
@@ -354,8 +328,9 @@ module DSPy
354
328
  })
355
329
 
356
330
  # Add timing and request correlation if available
357
- request_id = Thread.current[:dspy_request_id]
358
- start_time = Thread.current[:dspy_request_start_time]
331
+ context = DSPy::Context.current
332
+ request_id = context[:request_id]
333
+ start_time = context[:request_start_time]
359
334
 
360
335
  if request_id
361
336
  event_attributes['request_id'] = request_id
@@ -411,53 +386,21 @@ module DSPy
411
386
  end
412
387
  end
413
388
 
414
- public
415
-
416
- def validate_messages!(messages)
417
- unless messages.is_a?(Array)
418
- raise ArgumentError, "messages must be an array"
419
- end
420
-
421
- messages.each_with_index do |message, index|
422
- # Accept both Message objects and hash format for backward compatibility
423
- if message.is_a?(Message)
424
- # Already validated by type system
425
- next
426
- elsif message.is_a?(Hash) && message.key?(:role) && message.key?(:content)
427
- # Legacy hash format - validate role
428
- valid_roles = %w[system user assistant]
429
- unless valid_roles.include?(message[:role])
430
- raise ArgumentError, "Invalid role at index #{index}: #{message[:role]}. Must be one of: #{valid_roles.join(', ')}"
431
- end
432
- else
433
- raise ArgumentError, "Message at index #{index} must be a Message object or hash with :role and :content"
434
- end
435
- end
436
- end
437
-
438
389
  def execute_raw_chat(messages, &streaming_block)
439
390
  # Generate unique request ID for tracking
440
391
  request_id = SecureRandom.hex(8)
441
392
  start_time = Time.now
442
-
443
- # Store request context for correlation
444
- Thread.current[:dspy_request_id] = request_id
445
- Thread.current[:dspy_request_start_time] = start_time
446
-
447
- begin
393
+
394
+ DSPy::Context.with_request(request_id, start_time) do
448
395
  response = instrument_lm_request(messages, 'RawPrompt') do
449
396
  # Convert messages to hash format for adapter
450
397
  hash_messages = messages_to_hash_array(messages)
451
398
  # Direct adapter call, no strategies or JSON parsing
452
399
  adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
453
400
  end
454
-
401
+
455
402
  # Return raw response content, not parsed JSON
456
403
  response.content
457
- ensure
458
- # Clean up thread-local storage
459
- Thread.current[:dspy_request_id] = nil
460
- Thread.current[:dspy_request_start_time] = nil
461
404
  end
462
405
  end
463
406
 
@@ -475,23 +418,28 @@ module DSPy
475
418
  messages.each_with_index do |msg, index|
476
419
  if msg.is_a?(Message)
477
420
  normalized << msg
478
- elsif msg.is_a?(Hash)
479
- # Validate hash has required fields
480
- unless msg.key?(:role) && msg.key?(:content)
421
+ elsif msg.is_a?(Hash) || msg.respond_to?(:to_h)
422
+ data = msg.is_a?(Hash) ? msg : msg.to_h
423
+ unless data.is_a?(Hash)
424
+ raise ArgumentError, "Message at index #{index} must be a Message object or hash with :role and :content"
425
+ end
426
+
427
+ normalized_hash = data.transform_keys(&:to_sym)
428
+ unless normalized_hash.key?(:role) && normalized_hash.key?(:content)
481
429
  raise ArgumentError, "Message at index #{index} must have :role and :content"
482
430
  end
483
-
484
- # Validate role
431
+
432
+ role = normalized_hash[:role].to_s
485
433
  valid_roles = %w[system user assistant]
486
- unless valid_roles.include?(msg[:role])
487
- raise ArgumentError, "Invalid role at index #{index}: #{msg[:role]}. Must be one of: #{valid_roles.join(', ')}"
434
+ unless valid_roles.include?(role)
435
+ raise ArgumentError, "Invalid role at index #{index}: #{normalized_hash[:role]}. Must be one of: #{valid_roles.join(', ')}"
488
436
  end
489
-
490
- # Create Message object
491
- message = MessageFactory.create(msg)
437
+
438
+ message = MessageFactory.create(normalized_hash)
492
439
  if message.nil?
493
440
  raise ArgumentError, "Failed to create Message from hash at index #{index}"
494
441
  end
442
+
495
443
  normalized << message
496
444
  else
497
445
  raise ArgumentError, "Message at index #{index} must be a Message object or hash with :role and :content"