dspy 0.34.3 → 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.
data/lib/dspy/module.rb CHANGED
@@ -261,21 +261,64 @@ module DSPy
261
261
  def instrument_forward_call(call_args, call_kwargs)
262
262
  ensure_module_subscriptions!
263
263
 
264
+ input_json = serialize_module_input(call_args, call_kwargs)
265
+ root_call = DSPy::Context.current[:span_stack].empty?
266
+
264
267
  DSPy::Context.with_module(self) do
265
268
  observation_type = DSPy::ObservationType.for_module_class(self.class)
266
269
  span_attributes = observation_type.langfuse_attributes.merge(
267
- 'langfuse.observation.input' => serialize_module_input(call_args, call_kwargs),
270
+ 'langfuse.observation.input' => input_json,
268
271
  'dspy.module' => self.class.name
269
272
  )
273
+ operation_name = "#{self.class.name}.forward"
274
+ span_attributes.merge!(root_trace_attributes(call_args, call_kwargs, input_json)) if root_call
275
+
276
+ if self.class.name == 'DSPy::Predict' && respond_to?(:signature_class)
277
+ signature_name = signature_class&.name
278
+ span_attributes['dspy.signature'] = signature_name || 'anonymous'
279
+ span_attributes['dspy.signature_kind'] = infer_signature_kind(signature_name)
280
+ span_attributes['dspy.predictor_label'] = module_scope_label if module_scope_label
281
+ operation_name = "DSPy::Predict(#{signature_name}).forward" if signature_name
282
+ end
270
283
 
271
284
  DSPy::Context.with_span(
272
- operation: "#{self.class.name}.forward",
285
+ operation: operation_name,
273
286
  **span_attributes
274
287
  ) do |span|
275
- yield.tap do |result|
276
- if span && result
277
- span.set_attribute('langfuse.observation.output', serialize_module_output(result))
288
+ begin
289
+ yield.tap do |result|
290
+ if span && !result.nil?
291
+ span.set_attribute('langfuse.observation.output', serialize_module_output(result))
292
+ span.set_attribute('langfuse.observation.status', 'completed')
293
+ span.set_attribute('dspy.status', 'completed')
294
+ if root_call
295
+ span.set_attribute('langfuse.trace.output', serialize_module_output(result))
296
+ span.set_attribute('langfuse.trace.status', 'completed')
297
+ end
298
+ end
299
+ end
300
+ rescue StandardError => e
301
+ if span
302
+ span.set_attribute('langfuse.observation.output', serialize_module_error_output(e))
303
+ span.set_attribute('langfuse.observation.status', 'error')
304
+ span.set_attribute('dspy.error.class', e.class.name)
305
+ span.set_attribute('dspy.error.message', e.message.to_s[0, 2000]) if e.message
306
+ span.set_attribute('dspy.status', 'error')
307
+ if root_call
308
+ span.set_attribute('langfuse.trace.output', serialize_module_error_output(e))
309
+ span.set_attribute('langfuse.trace.status', 'error')
310
+ end
311
+ if e.respond_to?(:iterations)
312
+ span.set_attribute('dspy.error.iterations', e.iterations.to_i) unless e.iterations.nil?
313
+ end
314
+ if e.respond_to?(:max_iterations)
315
+ span.set_attribute('dspy.error.max_iterations', e.max_iterations.to_i) unless e.max_iterations.nil?
316
+ end
317
+ if e.respond_to?(:tools_used)
318
+ span.set_attribute('dspy.error.tools_used', Array(e.tools_used).map(&:to_s))
319
+ end
278
320
  end
321
+ raise
279
322
  end
280
323
  end
281
324
  end
@@ -303,7 +346,91 @@ module DSPy
303
346
  result.to_s
304
347
  end
305
348
 
306
- private :instrument_forward_call, :serialize_module_input, :serialize_module_output
349
+ def serialize_module_error_output(error)
350
+ payload = {
351
+ error: {
352
+ class: error.class.name,
353
+ message: error.message.to_s
354
+ }
355
+ }
356
+
357
+ if error.respond_to?(:iterations) || error.respond_to?(:max_iterations) || error.respond_to?(:tools_used)
358
+ payload[:react] = {}
359
+ payload[:react][:iterations] = error.iterations if error.respond_to?(:iterations)
360
+ payload[:react][:max_iterations] = error.max_iterations if error.respond_to?(:max_iterations)
361
+ payload[:react][:tools_used] = Array(error.tools_used) if error.respond_to?(:tools_used)
362
+ end
363
+
364
+ serialized = DSPy::TypeSerializer.serialize(payload)
365
+ JSON.generate(serialized)
366
+ rescue StandardError
367
+ "#{error.class}: #{error.message}"
368
+ end
369
+
370
+ def root_trace_attributes(call_args, call_kwargs, input_json)
371
+ metadata = {
372
+ module: self.class.name,
373
+ signature: (respond_to?(:signature_class) ? signature_class&.name : nil),
374
+ signature_kind: (respond_to?(:signature_class) ? infer_signature_kind(signature_class&.name) : nil),
375
+ predictor_label: module_scope_label
376
+ }.compact
377
+ conversation_id, conversation_id_source = resolve_conversation_id(call_args, call_kwargs)
378
+ metadata[:conversation_id_source] = conversation_id_source if conversation_id_source
379
+
380
+ {
381
+ 'langfuse.trace.name' => "#{self.class.name}.forward",
382
+ 'langfuse.trace.input' => input_json,
383
+ 'langfuse.trace.metadata' => JSON.generate(metadata),
384
+ 'langfuse.trace.output' => '{"status":"in_progress"}',
385
+ 'conversation_id' => conversation_id,
386
+ 'dspy.conversation_id' => conversation_id
387
+ }
388
+ rescue StandardError
389
+ {}
390
+ end
391
+
392
+ # Conversation ID precedence is deterministic:
393
+ # 1. top-level kwargs[:conversation_id]
394
+ # 2. first positional hash arg[:conversation_id]
395
+ # 3. kwargs[:input_context][:conversation_id]
396
+ # 4. DSPy::Context.current[:conversation_id]
397
+ def resolve_conversation_id(call_args, call_kwargs)
398
+ direct = fetch_hash_value(call_kwargs, :conversation_id)
399
+ return [direct.to_s, 'kwargs.conversation_id'] if present_value?(direct)
400
+
401
+ first_arg = call_args.first if call_args.is_a?(Array) && call_args.first.is_a?(Hash)
402
+ arg_value = fetch_hash_value(first_arg, :conversation_id)
403
+ return [arg_value.to_s, 'args[0].conversation_id'] if present_value?(arg_value)
404
+
405
+ input_context = fetch_hash_value(call_kwargs, :input_context)
406
+ nested = fetch_hash_value(input_context, :conversation_id)
407
+ return [nested.to_s, 'kwargs.input_context.conversation_id'] if present_value?(nested)
408
+
409
+ context_value = fetch_hash_value(DSPy::Context.current, :conversation_id)
410
+ return [context_value.to_s, 'context.conversation_id'] if present_value?(context_value)
411
+
412
+ [nil, nil]
413
+ end
414
+
415
+ def fetch_hash_value(hash, key)
416
+ return nil unless hash.is_a?(Hash)
417
+
418
+ hash[key] || hash[key.to_s]
419
+ end
420
+
421
+ def present_value?(value)
422
+ !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
423
+ end
424
+
425
+ def infer_signature_kind(signature_name)
426
+ return 'custom' unless signature_name
427
+ return 'thought' if signature_name.match?(/thought/i)
428
+ return 'observation' if signature_name.match?(/observation/i)
429
+
430
+ 'custom'
431
+ end
432
+
433
+ private :instrument_forward_call, :serialize_module_input, :serialize_module_output, :serialize_module_error_output, :root_trace_attributes, :resolve_conversation_id, :fetch_hash_value, :present_value?, :infer_signature_kind
307
434
 
308
435
  sig { returns(String) }
309
436
  def module_scope_id
data/lib/dspy/predict.rb CHANGED
@@ -304,7 +304,7 @@ module DSPy
304
304
  next unless prop_type
305
305
 
306
306
  # For nilable fields with nil values, ensure proper handling
307
- if value.nil? && is_nilable_type?(prop_type)
307
+ if value.nil? && nilable_type?(prop_type)
308
308
  # For nilable fields, nil is valid - keep it as is
309
309
  next
310
310
  elsif value.nil? && prop_info[:fully_optional]
@@ -1,6 +1,8 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative 'utils/serialization'
5
+
4
6
  module DSPy
5
7
  class Prediction
6
8
  extend T::Sig
@@ -54,7 +56,14 @@ module DSPy
54
56
 
55
57
  sig { returns(T::Hash[Symbol, T.untyped]) }
56
58
  def to_h
57
- @_struct.serialize
59
+ hash = DSPy::Utils::Serialization.deep_serialize(@_struct.serialize)
60
+ hash.delete('_prediction_marker')
61
+ hash
62
+ end
63
+
64
+ sig { params(args: T.untyped).returns(String) }
65
+ def to_json(*args)
66
+ to_h.to_json(*args)
58
67
  end
59
68
 
60
69
  private
data/lib/dspy/prompt.rb CHANGED
@@ -5,6 +5,7 @@ require 'sorbet-runtime'
5
5
  require 'sorbet/toon'
6
6
 
7
7
  require_relative 'few_shot_example'
8
+ require_relative 'utils/serialization'
8
9
  require_relative 'schema/sorbet_toon_adapter'
9
10
 
10
11
  module DSPy
@@ -241,7 +242,7 @@ module DSPy
241
242
  else
242
243
  sections << "## Input Values"
243
244
  sections << "```json"
244
- sections << JSON.pretty_generate(serialize_for_json(input_values))
245
+ sections << JSON.pretty_generate(DSPy::Utils::Serialization.deep_serialize(input_values))
245
246
  sections << "```"
246
247
  sections << ""
247
248
  sections << "Respond with the corresponding output schema fields wrapped in a ```json ``` block,"
@@ -376,51 +377,6 @@ module DSPy
376
377
  "# Please install: gem install sorbet-baml"
377
378
  end
378
379
 
379
- # Recursively serialize complex objects for JSON representation
380
- sig { params(obj: T.untyped).returns(T.untyped) }
381
- def serialize_for_json(obj)
382
- case obj
383
- when T::Struct
384
- # Convert T::Struct to hash using to_h method if available
385
- if obj.respond_to?(:to_h)
386
- serialize_for_json(obj.to_h)
387
- else
388
- # Fallback: serialize using struct properties
389
- serialize_struct_to_hash(obj)
390
- end
391
- when Hash
392
- # Recursively serialize hash values
393
- obj.transform_values { |v| serialize_for_json(v) }
394
- when Array
395
- # Recursively serialize array elements
396
- obj.map { |item| serialize_for_json(item) }
397
- when T::Enum
398
- # Serialize enums to their string representation
399
- obj.serialize
400
- else
401
- # For basic types (String, Integer, Float, Boolean, etc.), return as-is
402
- obj
403
- end
404
- end
405
-
406
- # Fallback method to serialize T::Struct to hash when to_h is not available
407
- sig { params(struct_obj: T::Struct).returns(T::Hash[Symbol, T.untyped]) }
408
- def serialize_struct_to_hash(struct_obj)
409
- result = {}
410
-
411
- # Use struct's props method to get all properties
412
- if struct_obj.class.respond_to?(:props)
413
- struct_obj.class.props.each do |prop_name, _prop_info|
414
- if struct_obj.respond_to?(prop_name)
415
- value = struct_obj.public_send(prop_name)
416
- result[prop_name] = serialize_for_json(value)
417
- end
418
- end
419
- end
420
-
421
- result
422
- end
423
-
424
380
  def toon_data_format_enabled?
425
381
  data_format == :toon && @signature_class
426
382
  end
data/lib/dspy/re_act.rb CHANGED
@@ -47,7 +47,56 @@ module DSPy
47
47
  include Mixins::StructBuilder
48
48
 
49
49
  # Custom error classes
50
- 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
51
100
  class InvalidActionError < StandardError; end
52
101
  class TypeMismatchError < StandardError; end
53
102
 
@@ -201,19 +250,17 @@ module DSPy
201
250
 
202
251
  sig { params(input_struct: T.untyped).returns(T.untyped) }
203
252
  def format_input_context(input_struct)
204
- return input_struct if toon_data_format?
205
-
206
- DSPy::TypeSerializer.serialize(input_struct).to_json
253
+ input_struct
207
254
  end
208
255
 
209
256
  sig { params(history: T::Array[HistoryEntry]).returns(T.untyped) }
210
257
  def format_history(history)
211
- toon_data_format? ? history : serialize_history_for_llm(history)
258
+ history
212
259
  end
213
260
 
214
261
  sig { params(observation: T.untyped).returns(T.untyped) }
215
262
  def format_observation(observation)
216
- toon_data_format? ? observation : serialize_for_llm(observation)
263
+ observation
217
264
  end
218
265
 
219
266
  sig { returns(T::Boolean) }
@@ -253,11 +300,7 @@ module DSPy
253
300
  sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
254
301
  def create_thought_signature(signature_class, data_format)
255
302
  action_enum_class = @action_enum_class
256
- input_context_type = if data_format == :toon
257
- signature_class.input_struct_class || String
258
- else
259
- String
260
- end
303
+ input_context_type = signature_class.input_struct_class || String
261
304
 
262
305
  # Get the output field type for the final_answer field
263
306
  output_field_name = signature_class.output_struct_class.props.keys.first
@@ -271,7 +314,7 @@ module DSPy
271
314
  # Define input fields
272
315
  input do
273
316
  const :input_context, input_context_type,
274
- 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"
275
318
  const :history, T::Array[HistoryEntry],
276
319
  description: "Previous thoughts and actions, including observations from tools."
277
320
  const :available_tools, T::Array[AvailableTool],
@@ -295,11 +338,7 @@ module DSPy
295
338
  # Creates a dynamic observation signature that includes the original input fields
296
339
  sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
297
340
  def create_observation_signature(signature_class, data_format)
298
- input_context_type = if data_format == :toon
299
- signature_class.input_struct_class || String
300
- else
301
- String
302
- end
341
+ input_context_type = signature_class.input_struct_class || String
303
342
  # Create new class that inherits from DSPy::Signature
304
343
  Class.new(DSPy::Signature) do
305
344
  # Set description
@@ -308,7 +347,7 @@ module DSPy
308
347
  # Define input fields
309
348
  input do
310
349
  const :input_context, input_context_type,
311
- 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"
312
351
  const :history, T::Array[HistoryEntry],
313
352
  description: "Previous thoughts, actions, and observations."
314
353
  const :observation, T.untyped,
@@ -392,7 +431,7 @@ module DSPy
392
431
  if finish_action?(thought_obj.action)
393
432
  final_answer = handle_finish_action(
394
433
  thought_obj.final_answer, last_observation, iteration,
395
- thought_obj.thought, thought_obj.action, history
434
+ thought_obj.thought, thought_obj.action, history, expected_output_field_type
396
435
  )
397
436
  return { should_finish: true, final_answer: final_answer }
398
437
  end
@@ -416,7 +455,7 @@ module DSPy
416
455
 
417
456
  # Process observation and decide next step
418
457
  observation_decision = process_observation_and_decide_next_step(
419
- input_struct, history, observation, available_tools_desc, iteration
458
+ input_struct, history, observation, available_tools_desc, iteration, tools_used
420
459
  )
421
460
 
422
461
  if observation_decision[:should_finish]
@@ -463,7 +502,8 @@ module DSPy
463
502
  if final_answer.nil?
464
503
  iterations = reasoning_result[:iterations]
465
504
  tools_used = reasoning_result[:tools_used]
466
- 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)
467
507
  end
468
508
 
469
509
  output_data = input_kwargs.merge({
@@ -489,6 +529,19 @@ module DSPy
489
529
  history.reverse.find { |entry| !entry.observation.nil? }&.observation
490
530
  end
491
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
+
492
545
  # Deserialize final answer to match expected output type
493
546
  # Routes to appropriate deserialization based on type classification
494
547
  sig { params(final_answer: T.untyped, output_field_type: T.untyped, history: T::Array[HistoryEntry]).returns(T.untyped) }
@@ -532,6 +585,12 @@ module DSPy
532
585
  return last_tool_observation
533
586
  end
534
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
+
535
594
  # If final_answer already matches, use it
536
595
  return final_answer if type_matches?(final_answer, output_field_type)
537
596
 
@@ -572,7 +631,11 @@ module DSPy
572
631
  def type_matches?(value, type_object)
573
632
  case type_object
574
633
  when T::Types::TypedArray
575
- 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) }
576
639
  when T::Types::TypedHash
577
640
  value.is_a?(Hash)
578
641
  when T::Types::Simple
@@ -622,12 +685,25 @@ module DSPy
622
685
  'dspy.module' => 'ReAct',
623
686
  'react.iteration' => iteration,
624
687
  'tool.name' => action_str.downcase,
688
+ 'langfuse.observation.input' => serialize_tool_payload(tool_input),
625
689
  'tool.input' => tool_input
626
- ) do
627
- 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
628
696
  end
629
697
  end
630
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
+
631
707
  sig { params(step: Integer, thought: String, action: String, tool_input: ToolInput, observation: T.untyped).returns(HistoryEntry) }
632
708
  def create_history_entry(step, thought, action, tool_input, observation)
633
709
  HistoryEntry.new(
@@ -639,8 +715,8 @@ module DSPy
639
715
  )
640
716
  end
641
717
 
642
- 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]) }
643
- 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)
644
720
  observation_result = @observation_processor.forward(
645
721
  input_context: format_input_context(input_struct),
646
722
  history: format_history(history),
@@ -650,14 +726,14 @@ module DSPy
650
726
  return { should_finish: false } unless observation_result.next_step == NextStep::Finish
651
727
 
652
728
  final_answer = generate_forced_final_answer(
653
- input_struct, history, available_tools_desc, observation_result, iteration
729
+ input_struct, history, available_tools_desc, observation_result, iteration, tools_used
654
730
  )
655
731
 
656
732
  { should_finish: true, final_answer: final_answer }
657
733
  end
658
734
 
659
- 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) }
660
- 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)
661
737
  final_thought = @thought_generator.forward(
662
738
  input_context: format_input_context(input_struct),
663
739
  history: format_history(history),
@@ -665,6 +741,8 @@ module DSPy
665
741
  )
666
742
 
667
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
+
668
746
  if action_str.downcase != FINISH_ACTION
669
747
  # Use interpretation if available, otherwise use last observation
670
748
  forced_answer = if observation_result.interpretation && !observation_result.interpretation.empty?
@@ -674,9 +752,23 @@ module DSPy
674
752
  else
675
753
  raise MaxIterationsError, "Observation processor indicated finish but no answer is available"
676
754
  end
677
- 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
678
770
  else
679
- 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)
680
772
  end
681
773
  end
682
774
 
@@ -738,13 +830,14 @@ module DSPy
738
830
  example
739
831
  end
740
832
 
741
- 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) }
742
- 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)
743
835
  final_answer = final_answer_value
744
836
 
745
837
  # If final_answer is empty/nil but we have a last observation, use it
746
838
  if (final_answer.nil? || (final_answer.is_a?(String) && final_answer.empty?)) && last_observation
747
- 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?
748
841
  end
749
842
 
750
843
  # Convert action enum to string for storage in history
@@ -761,5 +854,37 @@ module DSPy
761
854
 
762
855
  final_answer
763
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
764
889
  end
765
890
  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
@@ -80,7 +80,7 @@ module DSPy
80
80
 
81
81
  sections << "## Input Values"
82
82
  sections << "```json"
83
- sections << JSON.pretty_generate(serialize_for_json(input_values))
83
+ sections << JSON.pretty_generate(DSPy::Utils::Serialization.deep_serialize(input_values))
84
84
  sections << "```"
85
85
 
86
86
  sections.join("\n")
@@ -13,6 +13,8 @@ module DSPy
13
13
  when T::Struct
14
14
  # Use the serialize method to convert to a plain hash
15
15
  deep_serialize(obj.serialize)
16
+ when T::Enum
17
+ obj.serialize
16
18
  when Hash
17
19
  # Recursively serialize hash values
18
20
  obj.transform_values { |v| deep_serialize(v) }
@@ -24,12 +26,6 @@ module DSPy
24
26
  obj
25
27
  end
26
28
  end
27
-
28
- # Serializes an object to JSON with proper T::Struct handling
29
- sig { params(obj: T.untyped).returns(String) }
30
- def self.to_json(obj)
31
- deep_serialize(obj).to_json
32
- end
33
29
  end
34
30
  end
35
31
  end
data/lib/dspy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSPy
4
- VERSION = "0.34.3"
4
+ VERSION = "0.34.4"
5
5
  end