dspy 0.34.2 → 0.34.4

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -16
  3. data/lib/dspy/chain_of_thought.rb +3 -2
  4. data/lib/dspy/context.rb +70 -21
  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 +28 -67
  11. data/lib/dspy/lm/message.rb +1 -1
  12. data/lib/dspy/lm/response.rb +2 -2
  13. data/lib/dspy/lm/usage.rb +35 -10
  14. data/lib/dspy/lm.rb +22 -51
  15. data/lib/dspy/mixins/type_coercion.rb +256 -35
  16. data/lib/dspy/module.rb +203 -31
  17. data/lib/dspy/predict.rb +33 -6
  18. data/lib/dspy/prediction.rb +25 -58
  19. data/lib/dspy/prompt.rb +52 -76
  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 +159 -196
  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/signature.rb +4 -5
  29. data/lib/dspy/storage/program_storage.rb +2 -2
  30. data/lib/dspy/structured_outputs_prompt.rb +4 -4
  31. data/lib/dspy/teleprompt/utils.rb +2 -2
  32. data/lib/dspy/tools/github_cli_toolset.rb +7 -7
  33. data/lib/dspy/tools/text_processing_toolset.rb +2 -2
  34. data/lib/dspy/tools/toolset.rb +1 -1
  35. data/lib/dspy/utils/serialization.rb +2 -6
  36. data/lib/dspy/version.rb +1 -1
  37. data/lib/dspy.rb +50 -5
  38. metadata +7 -26
  39. data/lib/dspy/events/subscriber_mixin.rb +0 -79
  40. data/lib/dspy/events/subscribers.rb +0 -43
  41. data/lib/dspy/memory/embedding_engine.rb +0 -68
  42. data/lib/dspy/memory/in_memory_store.rb +0 -216
  43. data/lib/dspy/memory/local_embedding_engine.rb +0 -244
  44. data/lib/dspy/memory/memory_compactor.rb +0 -298
  45. data/lib/dspy/memory/memory_manager.rb +0 -266
  46. data/lib/dspy/memory/memory_record.rb +0 -163
  47. data/lib/dspy/memory/memory_store.rb +0 -90
  48. data/lib/dspy/memory.rb +0 -30
  49. data/lib/dspy/tools/memory_toolset.rb +0 -117
data/lib/dspy/re_act.rb CHANGED
@@ -33,21 +33,6 @@ module DSPy
33
33
  }
34
34
  end
35
35
  end
36
- # Base class for ReAct thought generation - will be customized per input type
37
- class ThoughtBase < DSPy::Signature
38
- description "Generate a thought about what to do next to process the given inputs."
39
-
40
- output do
41
- const :thought, String,
42
- description: "Reasoning about what to do next, considering the history and observations."
43
- const :action, String,
44
- description: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
45
- const :tool_input, ToolInput,
46
- description: "Input for the chosen tool action. Required when action is a tool name. MUST be a JSON object matching the tool's parameter schema. Set to null when action is \"finish\"."
47
- const :final_answer, T.nilable(String),
48
- description: "The final answer to return. Required when action is \"finish\". Must match the expected output type. Set to null when action is a tool name."
49
- end
50
- end
51
36
 
52
37
  class NextStep < T::Enum
53
38
  enums do
@@ -56,25 +41,62 @@ module DSPy
56
41
  end
57
42
  end
58
43
 
59
- # Base class for observation processing - will be customized per input type
60
- class ReActObservationBase < DSPy::Signature
61
- description "Process the observation from a tool and decide what to do next."
62
-
63
- output do
64
- const :interpretation, String,
65
- description: "Interpretation of the observation"
66
- const :next_step, NextStep,
67
- description: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
68
- end
69
- end
70
-
71
44
  # ReAct Agent using Sorbet signatures
72
45
  class ReAct < Predict
73
46
  extend T::Sig
74
47
  include Mixins::StructBuilder
75
48
 
76
49
  # Custom error classes
77
- class MaxIterationsError < StandardError; end
50
+ class MaxIterationsError < StandardError
51
+ extend T::Sig
52
+
53
+ sig { returns(T.nilable(Integer)) }
54
+ attr_reader :iterations
55
+
56
+ sig { returns(T.nilable(Integer)) }
57
+ attr_reader :max_iterations
58
+
59
+ sig { returns(T::Array[String]) }
60
+ attr_reader :tools_used
61
+
62
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
63
+ attr_reader :history
64
+
65
+ sig { returns(T.untyped) }
66
+ attr_reader :last_observation
67
+
68
+ sig { returns(T.untyped) }
69
+ attr_reader :partial_final_answer
70
+
71
+ sig do
72
+ params(
73
+ message: String,
74
+ iterations: T.nilable(Integer),
75
+ max_iterations: T.nilable(Integer),
76
+ tools_used: T::Array[String],
77
+ history: T::Array[T::Hash[Symbol, T.untyped]],
78
+ last_observation: T.untyped,
79
+ partial_final_answer: T.untyped
80
+ ).void
81
+ end
82
+ def initialize(
83
+ message = "Agent reached maximum iterations without producing a final answer",
84
+ iterations: nil,
85
+ max_iterations: nil,
86
+ tools_used: [],
87
+ history: [],
88
+ last_observation: nil,
89
+ partial_final_answer: nil
90
+ )
91
+ @iterations = T.let(iterations, T.nilable(Integer))
92
+ @max_iterations = T.let(max_iterations, T.nilable(Integer))
93
+ @tools_used = T.let(tools_used, T::Array[String])
94
+ @history = T.let(history, T::Array[T::Hash[Symbol, T.untyped]])
95
+ @last_observation = last_observation
96
+ @partial_final_answer = partial_final_answer
97
+ super(message)
98
+ end
99
+ end
78
100
  class InvalidActionError < StandardError; end
79
101
  class TypeMismatchError < StandardError; end
80
102
 
@@ -228,19 +250,17 @@ module DSPy
228
250
 
229
251
  sig { params(input_struct: T.untyped).returns(T.untyped) }
230
252
  def format_input_context(input_struct)
231
- return input_struct if toon_data_format?
232
-
233
- DSPy::TypeSerializer.serialize(input_struct).to_json
253
+ input_struct
234
254
  end
235
255
 
236
256
  sig { params(history: T::Array[HistoryEntry]).returns(T.untyped) }
237
257
  def format_history(history)
238
- toon_data_format? ? history : serialize_history_for_llm(history)
258
+ history
239
259
  end
240
260
 
241
261
  sig { params(observation: T.untyped).returns(T.untyped) }
242
262
  def format_observation(observation)
243
- toon_data_format? ? observation : serialize_for_llm(observation)
263
+ observation
244
264
  end
245
265
 
246
266
  sig { returns(T::Boolean) }
@@ -280,11 +300,7 @@ module DSPy
280
300
  sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
281
301
  def create_thought_signature(signature_class, data_format)
282
302
  action_enum_class = @action_enum_class
283
- input_context_type = if data_format == :toon
284
- signature_class.input_struct_class || String
285
- else
286
- String
287
- end
303
+ input_context_type = signature_class.input_struct_class || String
288
304
 
289
305
  # Get the output field type for the final_answer field
290
306
  output_field_name = signature_class.output_struct_class.props.keys.first
@@ -298,7 +314,7 @@ module DSPy
298
314
  # Define input fields
299
315
  input do
300
316
  const :input_context, input_context_type,
301
- description: data_format == :toon ? "All original input fields with their typed values" : "Serialized representation of all input fields"
317
+ description: "All original input fields with their typed values"
302
318
  const :history, T::Array[HistoryEntry],
303
319
  description: "Previous thoughts and actions, including observations from tools."
304
320
  const :available_tools, T::Array[AvailableTool],
@@ -322,11 +338,7 @@ module DSPy
322
338
  # Creates a dynamic observation signature that includes the original input fields
323
339
  sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
324
340
  def create_observation_signature(signature_class, data_format)
325
- input_context_type = if data_format == :toon
326
- signature_class.input_struct_class || String
327
- else
328
- String
329
- end
341
+ input_context_type = signature_class.input_struct_class || String
330
342
  # Create new class that inherits from DSPy::Signature
331
343
  Class.new(DSPy::Signature) do
332
344
  # Set description
@@ -335,7 +347,7 @@ module DSPy
335
347
  # Define input fields
336
348
  input do
337
349
  const :input_context, input_context_type,
338
- description: data_format == :toon ? "All original input fields with their typed values" : "Serialized representation of all input fields"
350
+ description: "All original input fields with their typed values"
339
351
  const :history, T::Array[HistoryEntry],
340
352
  description: "Previous thoughts, actions, and observations."
341
353
  const :observation, T.untyped,
@@ -419,7 +431,7 @@ module DSPy
419
431
  if finish_action?(thought_obj.action)
420
432
  final_answer = handle_finish_action(
421
433
  thought_obj.final_answer, last_observation, iteration,
422
- thought_obj.thought, thought_obj.action, history
434
+ thought_obj.thought, thought_obj.action, history, expected_output_field_type
423
435
  )
424
436
  return { should_finish: true, final_answer: final_answer }
425
437
  end
@@ -443,7 +455,7 @@ module DSPy
443
455
 
444
456
  # Process observation and decide next step
445
457
  observation_decision = process_observation_and_decide_next_step(
446
- input_struct, history, observation, available_tools_desc, iteration
458
+ input_struct, history, observation, available_tools_desc, iteration, tools_used
447
459
  )
448
460
 
449
461
  if observation_decision[:should_finish]
@@ -490,7 +502,8 @@ module DSPy
490
502
  if final_answer.nil?
491
503
  iterations = reasoning_result[:iterations]
492
504
  tools_used = reasoning_result[:tools_used]
493
- raise MaxIterationsError, "Agent reached maximum iterations (#{iterations}) without producing a final answer. Tools used: #{tools_used.join(', ')}"
505
+ history = T.cast(reasoning_result[:history], T::Array[HistoryEntry])
506
+ raise build_max_iterations_error(iterations: iterations, tools_used: tools_used, history: history)
494
507
  end
495
508
 
496
509
  output_data = input_kwargs.merge({
@@ -516,6 +529,19 @@ module DSPy
516
529
  history.reverse.find { |entry| !entry.observation.nil? }&.observation
517
530
  end
518
531
 
532
+ sig { params(iterations: Integer, tools_used: T::Array[String], history: T::Array[HistoryEntry], partial_final_answer: T.untyped).returns(MaxIterationsError) }
533
+ def build_max_iterations_error(iterations:, tools_used:, history:, partial_final_answer: nil)
534
+ MaxIterationsError.new(
535
+ "Agent reached maximum iterations (#{iterations}) without producing a final answer. Tools used: #{tools_used.join(', ')}",
536
+ iterations: iterations,
537
+ max_iterations: @max_iterations,
538
+ tools_used: tools_used.uniq,
539
+ history: history.map(&:to_h),
540
+ last_observation: find_last_tool_observation(history),
541
+ partial_final_answer: partial_final_answer
542
+ )
543
+ end
544
+
519
545
  # Deserialize final answer to match expected output type
520
546
  # Routes to appropriate deserialization based on type classification
521
547
  sig { params(final_answer: T.untyped, output_field_type: T.untyped, history: T::Array[HistoryEntry]).returns(T.untyped) }
@@ -559,6 +585,12 @@ module DSPy
559
585
  return last_tool_observation
560
586
  end
561
587
 
588
+ # Structured outputs frequently arrive as JSON strings; parse before coercion.
589
+ if final_answer.is_a?(String)
590
+ parsed = parse_json_string(final_answer)
591
+ final_answer = parsed unless parsed.nil?
592
+ end
593
+
562
594
  # If final_answer already matches, use it
563
595
  return final_answer if type_matches?(final_answer, output_field_type)
564
596
 
@@ -599,7 +631,11 @@ module DSPy
599
631
  def type_matches?(value, type_object)
600
632
  case type_object
601
633
  when T::Types::TypedArray
602
- value.is_a?(Array) && (value.empty? || value.first.is_a?(T::Struct))
634
+ return false unless value.is_a?(Array)
635
+ return true if value.empty?
636
+
637
+ element_type = type_object.type
638
+ value.all? { |item| type_matches?(item, element_type) }
603
639
  when T::Types::TypedHash
604
640
  value.is_a?(Hash)
605
641
  when T::Types::Simple
@@ -649,12 +685,25 @@ module DSPy
649
685
  'dspy.module' => 'ReAct',
650
686
  'react.iteration' => iteration,
651
687
  'tool.name' => action_str.downcase,
688
+ 'langfuse.observation.input' => serialize_tool_payload(tool_input),
652
689
  'tool.input' => tool_input
653
- ) do
654
- execute_action(action_str, tool_input)
690
+ ) do |span|
691
+ result = execute_action(action_str, tool_input)
692
+ if span
693
+ span.set_attribute('langfuse.observation.output', serialize_tool_payload(result))
694
+ end
695
+ result
655
696
  end
656
697
  end
657
698
 
699
+ sig { params(payload: T.untyped).returns(String) }
700
+ def serialize_tool_payload(payload)
701
+ serialized = DSPy::TypeSerializer.serialize(payload)
702
+ JSON.generate(serialized)
703
+ rescue StandardError
704
+ payload.to_s
705
+ end
706
+
658
707
  sig { params(step: Integer, thought: String, action: String, tool_input: ToolInput, observation: T.untyped).returns(HistoryEntry) }
659
708
  def create_history_entry(step, thought, action, tool_input, observation)
660
709
  HistoryEntry.new(
@@ -666,8 +715,8 @@ module DSPy
666
715
  )
667
716
  end
668
717
 
669
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: T.untyped, available_tools_desc: T::Array[AvailableTool], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
670
- def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration)
718
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: T.untyped, available_tools_desc: T::Array[AvailableTool], iteration: Integer, tools_used: T::Array[String]).returns(T::Hash[Symbol, T.untyped]) }
719
+ def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration, tools_used)
671
720
  observation_result = @observation_processor.forward(
672
721
  input_context: format_input_context(input_struct),
673
722
  history: format_history(history),
@@ -677,14 +726,14 @@ module DSPy
677
726
  return { should_finish: false } unless observation_result.next_step == NextStep::Finish
678
727
 
679
728
  final_answer = generate_forced_final_answer(
680
- input_struct, history, available_tools_desc, observation_result, iteration
729
+ input_struct, history, available_tools_desc, observation_result, iteration, tools_used
681
730
  )
682
731
 
683
732
  { should_finish: true, final_answer: final_answer }
684
733
  end
685
734
 
686
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], observation_result: T.untyped, iteration: Integer).returns(T.untyped) }
687
- def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
735
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], observation_result: T.untyped, iteration: Integer, tools_used: T::Array[String]).returns(T.untyped) }
736
+ def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration, tools_used)
688
737
  final_thought = @thought_generator.forward(
689
738
  input_context: format_input_context(input_struct),
690
739
  history: format_history(history),
@@ -692,6 +741,8 @@ module DSPy
692
741
  )
693
742
 
694
743
  action_str = final_thought.action.respond_to?(:serialize) ? final_thought.action.serialize : final_thought.action.to_s
744
+ output_field_type = expected_output_field_type
745
+
695
746
  if action_str.downcase != FINISH_ACTION
696
747
  # Use interpretation if available, otherwise use last observation
697
748
  forced_answer = if observation_result.interpretation && !observation_result.interpretation.empty?
@@ -701,9 +752,23 @@ module DSPy
701
752
  else
702
753
  raise MaxIterationsError, "Observation processor indicated finish but no answer is available"
703
754
  end
704
- handle_finish_action(forced_answer, history.last&.observation, iteration + 1, final_thought.thought, FINISH_ACTION, history)
755
+
756
+ if structured_type?(output_field_type)
757
+ coerced_forced_answer = coerce_candidate_for_output(forced_answer, output_field_type)
758
+ if coerced_forced_answer.nil?
759
+ raise build_max_iterations_error(
760
+ iterations: iteration,
761
+ tools_used: tools_used,
762
+ history: history,
763
+ partial_final_answer: forced_answer
764
+ )
765
+ end
766
+ handle_finish_action(coerced_forced_answer, history.last&.observation, iteration + 1, final_thought.thought, FINISH_ACTION, history, output_field_type)
767
+ else
768
+ handle_finish_action(forced_answer, history.last&.observation, iteration + 1, final_thought.thought, FINISH_ACTION, history, output_field_type)
769
+ end
705
770
  else
706
- handle_finish_action(final_thought.final_answer, history.last&.observation, iteration + 1, final_thought.thought, final_thought.action, history)
771
+ handle_finish_action(final_thought.final_answer, history.last&.observation, iteration + 1, final_thought.thought, final_thought.action, history, output_field_type)
707
772
  end
708
773
  end
709
774
 
@@ -731,113 +796,6 @@ module DSPy
731
796
  end
732
797
  end
733
798
 
734
- # Checks if a type is a scalar (primitives that don't need special serialization)
735
- sig { params(type_object: T.untyped).returns(T::Boolean) }
736
- def scalar_type?(type_object)
737
- case type_object
738
- when T::Types::Simple
739
- scalar_classes = [String, Integer, Float, Numeric, TrueClass, FalseClass]
740
- scalar_classes.any? { |klass| type_object.raw_type == klass || type_object.raw_type <= klass }
741
- when T::Types::Union
742
- # Union is scalar if all its types are scalars
743
- type_object.types.all? { |t| scalar_type?(t) }
744
- else
745
- false
746
- end
747
- end
748
-
749
- # Checks if a type is structured (arrays, hashes, structs that need type preservation)
750
- sig { params(type_object: T.untyped).returns(T::Boolean) }
751
- def structured_type?(type_object)
752
- return true if type_object.is_a?(T::Types::TypedArray)
753
- return true if type_object.is_a?(T::Types::TypedHash)
754
-
755
- if type_object.is_a?(T::Types::Simple)
756
- raw_type = type_object.raw_type
757
- return true if raw_type.respond_to?(:<=) && raw_type <= T::Struct
758
- end
759
-
760
- # For union types (like T.nilable(T::Array[...])), check if any non-nil type is structured
761
- if type_object.is_a?(T::Types::Union)
762
- non_nil_types = type_object.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
763
- return non_nil_types.any? { |t| structured_type?(t) }
764
- end
765
-
766
- false
767
- end
768
-
769
- # Checks if a type is String or compatible with String (e.g., T.any(String, ...) or T.nilable(String))
770
- sig { params(type_object: T.untyped).returns(T::Boolean) }
771
- def string_type?(type_object)
772
- case type_object
773
- when T::Types::Simple
774
- type_object.raw_type == String
775
- when T::Types::Union
776
- # Check if any of the union types is String
777
- type_object.types.any? { |t| t.is_a?(T::Types::Simple) && t.raw_type == String }
778
- else
779
- false
780
- end
781
- end
782
-
783
- # Alias for backward compatibility
784
- alias string_compatible_type? string_type?
785
-
786
- # Get a readable type name from a Sorbet type object
787
- sig { params(type_object: T.untyped).returns(String) }
788
- def type_name(type_object)
789
- case type_object
790
- when T::Types::TypedArray
791
- element_type = type_object.type
792
- "T::Array[#{type_name(element_type)}]"
793
- when T::Types::TypedHash
794
- "T::Hash"
795
- when T::Types::Simple
796
- type_object.raw_type.to_s
797
- when T::Types::Union
798
- types_str = type_object.types.map { |t| type_name(t) }.join(', ')
799
- "T.any(#{types_str})"
800
- else
801
- type_object.to_s
802
- end
803
- end
804
-
805
- # Returns an appropriate default value for a given Sorbet type
806
- # This is used when max iterations is reached without a successful completion
807
- sig { params(type_object: T.untyped).returns(T.untyped) }
808
- def default_value_for_type(type_object)
809
- # Handle TypedArray (T::Array[...])
810
- if type_object.is_a?(T::Types::TypedArray)
811
- return []
812
- end
813
-
814
- # Handle TypedHash (T::Hash[...])
815
- if type_object.is_a?(T::Types::TypedHash)
816
- return {}
817
- end
818
-
819
- # Handle simple types
820
- case type_object
821
- when T::Types::Simple
822
- raw_type = type_object.raw_type
823
- case raw_type.to_s
824
- when 'String' then ''
825
- when 'Integer' then 0
826
- when 'Float' then 0.0
827
- when 'TrueClass', 'FalseClass' then false
828
- else
829
- # For T::Struct types, return nil as fallback
830
- nil
831
- end
832
- when T::Types::Union
833
- # For unions, return nil (assuming it's nilable) or first non-nil default
834
- nil
835
- else
836
- # Default fallback for unknown types
837
- nil
838
- end
839
- end
840
-
841
799
  # Tool execution method
842
800
  sig { params(action: String, tool_input: ToolInput).returns(T.untyped) }
843
801
  def execute_action(action, tool_input)
@@ -855,34 +813,6 @@ module DSPy
855
813
  end
856
814
  end
857
815
 
858
- sig { params(output: T.untyped).void }
859
- def validate_output_schema!(output)
860
- # Validate that output is an instance of the enhanced output struct
861
- unless output.is_a?(@enhanced_output_struct)
862
- raise "Output must be an instance of #{@enhanced_output_struct}, got #{output.class}"
863
- end
864
-
865
- # Validate original signature output fields are present
866
- @original_signature_class.output_struct_class.props.each do |field_name, _prop|
867
- unless output.respond_to?(field_name)
868
- raise "Missing required field: #{field_name}"
869
- end
870
- end
871
-
872
- # Validate ReAct-specific fields
873
- unless output.respond_to?(:history) && output.history.is_a?(Array)
874
- raise "Missing or invalid history field"
875
- end
876
-
877
- unless output.respond_to?(:iterations) && output.iterations.is_a?(Integer)
878
- raise "Missing or invalid iterations field"
879
- end
880
-
881
- unless output.respond_to?(:tools_used) && output.tools_used.is_a?(Array)
882
- raise "Missing or invalid tools_used field"
883
- end
884
- end
885
-
886
816
  sig { override.returns(T::Hash[Symbol, T.untyped]) }
887
817
  def generate_example_output
888
818
  example = super
@@ -900,13 +830,14 @@ module DSPy
900
830
  example
901
831
  end
902
832
 
903
- sig { params(final_answer_value: T.untyped, last_observation: T.untyped, step: Integer, thought: String, action: T.any(String, T::Enum), history: T::Array[HistoryEntry]).returns(T.untyped) }
904
- def handle_finish_action(final_answer_value, last_observation, step, thought, action, history)
833
+ sig { params(final_answer_value: T.untyped, last_observation: T.untyped, step: Integer, thought: String, action: T.any(String, T::Enum), history: T::Array[HistoryEntry], output_field_type: T.untyped).returns(T.untyped) }
834
+ def handle_finish_action(final_answer_value, last_observation, step, thought, action, history, output_field_type)
905
835
  final_answer = final_answer_value
906
836
 
907
837
  # If final_answer is empty/nil but we have a last observation, use it
908
838
  if (final_answer.nil? || (final_answer.is_a?(String) && final_answer.empty?)) && last_observation
909
- final_answer = last_observation
839
+ fallback_value = coerce_candidate_for_output(last_observation, output_field_type)
840
+ final_answer = fallback_value unless fallback_value.nil?
910
841
  end
911
842
 
912
843
  # Convert action enum to string for storage in history
@@ -923,5 +854,37 @@ module DSPy
923
854
 
924
855
  final_answer
925
856
  end
857
+
858
+ sig { returns(T.untyped) }
859
+ def expected_output_field_type
860
+ output_field_name = @original_signature_class.output_struct_class.props.keys.first
861
+ @original_signature_class.output_struct_class.props[output_field_name][:type_object]
862
+ end
863
+
864
+ sig { params(value: T.untyped, output_field_type: T.untyped).returns(T.untyped) }
865
+ def coerce_candidate_for_output(value, output_field_type)
866
+ return value if type_matches?(value, output_field_type)
867
+
868
+ candidate = value
869
+ if candidate.is_a?(String) && structured_type?(output_field_type)
870
+ parsed = parse_json_string(candidate)
871
+ candidate = parsed unless parsed.nil?
872
+ end
873
+
874
+ return candidate if type_matches?(candidate, output_field_type)
875
+
876
+ converted = convert_to_expected_type(candidate, output_field_type)
877
+ return converted if type_matches?(converted, output_field_type)
878
+
879
+ nil
880
+ end
881
+
882
+ sig { params(value: String).returns(T.nilable(T.untyped)) }
883
+ def parse_json_string(value)
884
+ parsed = JSON.parse(value)
885
+ parsed
886
+ rescue JSON::ParserError
887
+ nil
888
+ end
926
889
  end
927
890
  end
@@ -259,7 +259,7 @@ module DSPy
259
259
  emit_register_complete_event(signature_version)
260
260
  signature_version
261
261
 
262
- rescue => error
262
+ rescue StandardError => error
263
263
  emit_register_error_event(signature_name, version, error)
264
264
  raise
265
265
  end
@@ -313,7 +313,7 @@ module DSPy
313
313
  emit_deploy_complete_event(deployed_version)
314
314
  deployed_version
315
315
 
316
- rescue => error
316
+ rescue StandardError => error
317
317
  emit_deploy_error_event(signature_name, version, error)
318
318
  nil
319
319
  end
@@ -363,7 +363,7 @@ module DSPy
363
363
  emit_rollback_error_event(signature_name, "No previous version to rollback to")
364
364
  nil
365
365
 
366
- rescue => error
366
+ rescue StandardError => error
367
367
  emit_rollback_error_event(signature_name, error.message)
368
368
  nil
369
369
  end
@@ -52,7 +52,7 @@ module DSPy
52
52
  # Validate vision support if images are present
53
53
  if contains_images?(normalized_messages)
54
54
  validate_vision_support!
55
- normalized_messages = format_multimodal_messages(normalized_messages)
55
+ normalized_messages = format_multimodal_messages(normalized_messages, provider)
56
56
  end
57
57
 
58
58
  chat_instance = create_chat_instance
@@ -358,32 +358,6 @@ module DSPy
358
358
  # If DSPy doesn't know about the model, let RubyLLM handle it
359
359
  # RubyLLM has its own model registry with capability detection
360
360
  end
361
-
362
- def format_multimodal_messages(messages)
363
- messages.map do |msg|
364
- if msg[:content].is_a?(Array)
365
- formatted_content = msg[:content].map do |item|
366
- case item[:type]
367
- when 'text'
368
- { type: 'text', text: item[:text] }
369
- when 'image'
370
- # Validate and format image for provider
371
- image = item[:image]
372
- if image.respond_to?(:validate_for_provider!)
373
- image.validate_for_provider!(provider)
374
- end
375
- item
376
- else
377
- item
378
- end
379
- end
380
-
381
- { role: msg[:role], content: formatted_content }
382
- else
383
- msg
384
- end
385
- end
386
- end
387
361
  end
388
362
  end
389
363
  end
@@ -195,10 +195,10 @@ module DSPy
195
195
  end
196
196
  else
197
197
  # Not nilable SimplePairUnion - this is a regular T.any() union
198
- # Generate oneOf schema for all types
198
+ # Generate anyOf schema for all types (oneOf not supported by Anthropic strict mode)
199
199
  if type.respond_to?(:types) && type.types.length > 1
200
200
  {
201
- oneOf: type.types.map { |t| type_to_json_schema_internal(t, visited, definitions) },
201
+ anyOf: type.types.map { |t| type_to_json_schema_internal(t, visited, definitions) },
202
202
  description: "Union of multiple types"
203
203
  }
204
204
  else
@@ -237,14 +237,14 @@ module DSPy
237
237
  # Non-nilable single type union (shouldn't happen in practice)
238
238
  type_to_json_schema_internal(non_nil_types.first, visited, definitions)
239
239
  elsif non_nil_types.size > 1
240
- # Handle complex unions with oneOf for better JSON schema compliance
240
+ # Handle complex unions with anyOf (oneOf not supported by Anthropic strict mode)
241
241
  base_schema = {
242
- oneOf: non_nil_types.map { |t| type_to_json_schema_internal(t, visited, definitions) },
242
+ anyOf: non_nil_types.map { |t| type_to_json_schema_internal(t, visited, definitions) },
243
243
  description: "Union of multiple types"
244
244
  }
245
245
  if is_nilable
246
246
  # Add null as an option for complex nilable unions
247
- base_schema[:oneOf] << { type: "null" }
247
+ base_schema[:anyOf] << { type: "null" }
248
248
  end
249
249
  base_schema
250
250
  else
@@ -335,7 +335,8 @@ module DSPy
335
335
  type: "object",
336
336
  properties: properties,
337
337
  required: required,
338
- description: "#{struct_name} struct"
338
+ description: "#{struct_name} struct",
339
+ additionalProperties: false
339
340
  }
340
341
 
341
342
  # Add this struct's schema to definitions for $defs
@@ -2,6 +2,6 @@
2
2
 
3
3
  module DSPy
4
4
  module Schema
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.2"
6
6
  end
7
7
  end
@@ -46,7 +46,7 @@ module DSPy
46
46
  begin
47
47
  result = from_hash(struct_class, hash_data)
48
48
  [true, result]
49
- rescue => e
49
+ rescue StandardError => e
50
50
  [false, [e.message]]
51
51
  end
52
52
  end
@@ -58,11 +58,10 @@ module DSPy
58
58
  Class.new(T::Struct) do
59
59
  extend T::Sig
60
60
  descriptors.each do |name, descriptor|
61
- if descriptor.has_default
62
- const name, descriptor.type, default: descriptor.default_value
63
- else
64
- const name, descriptor.type
65
- end
61
+ opts = {}
62
+ opts[:default] = descriptor.default_value if descriptor.has_default
63
+ opts[:description] = descriptor.description if descriptor.description
64
+ const name, descriptor.type, **opts
66
65
  end
67
66
  end
68
67
  end