dspy 0.27.4 → 0.27.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dca273acbd2ba8ab0ab9e2980bdf8048cc9ea80b3ce1b74d6f1e911e165ae895
4
- data.tar.gz: '08177e7841275140c7f074ac3a700883f06c6610c9bfd4795d142a3c838c0b24'
3
+ metadata.gz: ccadc1d2803420cbc9389d9f4312b6b6e616e133d4e35f36454135292fd8e837
4
+ data.tar.gz: e174d2cd9418a0294890e1ca6d3599681ba26c2a09c081f3b2b6652e6ce8c88d
5
5
  SHA512:
6
- metadata.gz: 1b8d4bd756ab303688366fb0bcb5e5a3e045152c5695a8cdcd5291ca6313fc8ee7bd8051c6026663cfda0ec429b7977aa2355442595ba803bfedafd3926e89b6
7
- data.tar.gz: 30efa43a8bc34708003a178d7715ae6688268b4b63e98789f011e47f7d36b8c30f292ac986c8df3767bafa517b7b73f5ff7fa92fec8013732c69952dfa79df71
6
+ metadata.gz: fa7eefd5f7d5555ce057f0e204b6aa5bac1188e2633c202abdf9decc75eee75ae5a232550c368dcba081fe32cc54e0d9aa8db6d565b280c260bd0b8beacff4d9
7
+ data.tar.gz: 5b16c7fe7ebbe678e16235e0b0b857d3b017deb7a7eea1e6e032b4b839fbde33b4bae86fd3e50d2b6f49c114303231f60441d43d931c8f0cda3c9f5e72d786ab
@@ -119,72 +119,12 @@ module DSPy
119
119
  sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
120
120
  def build_properties_from_fields(fields)
121
121
  properties = {}
122
-
123
- fields.each do |field_name, descriptor|
124
- properties[field_name.to_s] = convert_type_to_json_schema(descriptor.type)
125
- end
126
-
127
- properties
128
- end
129
122
 
130
- sig { params(type: T.untyped).returns(T::Hash[String, T.untyped]) }
131
- def convert_type_to_json_schema(type)
132
- # Handle raw Ruby class types - use === for class comparison
133
- if type == String
134
- return { type: "string" }
135
- elsif type == Integer
136
- return { type: "integer" }
137
- elsif type == Float
138
- return { type: "number" }
139
- elsif type == TrueClass || type == FalseClass
140
- return { type: "boolean" }
141
- end
142
-
143
- # Handle Sorbet types
144
- case type
145
- when T::Types::Simple
146
- case type.raw_type.to_s
147
- when "String"
148
- { type: "string" }
149
- when "Integer"
150
- { type: "integer" }
151
- when "Float", "Numeric"
152
- { type: "number" }
153
- when "TrueClass", "FalseClass"
154
- { type: "boolean" }
155
- else
156
- { type: "string" } # Default fallback
157
- end
158
- when T::Types::TypedArray
159
- {
160
- type: "array",
161
- items: convert_type_to_json_schema(type.type)
162
- }
163
- when T::Types::TypedHash
164
- {
165
- type: "object",
166
- additionalProperties: convert_type_to_json_schema(type.values)
167
- }
168
- else
169
- # For complex types, try to introspect
170
- if type.respond_to?(:props)
171
- {
172
- type: "object",
173
- properties: build_properties_from_props(type.props)
174
- }
175
- else
176
- { type: "object" } # Generic object fallback
177
- end
123
+ fields.each do |field_name, descriptor|
124
+ properties[field_name.to_s] = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
178
125
  end
179
- end
180
126
 
181
- sig { params(props: T.untyped).returns(T::Hash[String, T.untyped]) }
182
- def build_properties_from_props(props)
183
- result = {}
184
- props.each do |prop_name, prop_info|
185
- result[prop_name.to_s] = convert_type_to_json_schema(prop_info[:type])
186
- end
187
- result
127
+ properties
188
128
  end
189
129
  end
190
130
  end
data/lib/dspy/re_act.rb CHANGED
@@ -369,13 +369,22 @@ module DSPy
369
369
  sig { params(input_kwargs: T::Hash[Symbol, T.untyped], reasoning_result: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
370
370
  def create_enhanced_result(input_kwargs, reasoning_result)
371
371
  output_field_name = @original_signature_class.output_struct_class.props.keys.first
372
+ final_answer = reasoning_result[:final_answer]
372
373
 
373
374
  output_data = input_kwargs.merge({
374
375
  history: reasoning_result[:history].map(&:to_h),
375
376
  iterations: reasoning_result[:iterations],
376
377
  tools_used: reasoning_result[:tools_used]
377
378
  })
378
- output_data[output_field_name] = reasoning_result[:final_answer]
379
+
380
+ # Check if final_answer is a String but the expected type is NOT String
381
+ # This happens when max iterations is reached or the LLM generates an error message
382
+ output_field_type = @original_signature_class.output_struct_class.props[output_field_name][:type_object]
383
+ if final_answer.is_a?(String) && !string_compatible_type?(output_field_type)
384
+ output_data[output_field_name] = default_value_for_type(output_field_type)
385
+ else
386
+ output_data[output_field_name] = final_answer
387
+ end
379
388
 
380
389
  @enhanced_output_struct.new(**output_data)
381
390
  end
@@ -502,6 +511,56 @@ module DSPy
502
511
  "No answer reached within #{@max_iterations} iterations"
503
512
  end
504
513
 
514
+ # Checks if a type is String or compatible with String (e.g., T.any(String, ...) or T.nilable(String))
515
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
516
+ def string_compatible_type?(type_object)
517
+ case type_object
518
+ when T::Types::Simple
519
+ type_object.raw_type == String
520
+ when T::Types::Union
521
+ # Check if any of the union types is String
522
+ type_object.types.any? { |t| t.is_a?(T::Types::Simple) && t.raw_type == String }
523
+ else
524
+ false
525
+ end
526
+ end
527
+
528
+ # Returns an appropriate default value for a given Sorbet type
529
+ # This is used when max iterations is reached without a successful completion
530
+ sig { params(type_object: T.untyped).returns(T.untyped) }
531
+ def default_value_for_type(type_object)
532
+ # Handle TypedArray (T::Array[...])
533
+ if type_object.is_a?(T::Types::TypedArray)
534
+ return []
535
+ end
536
+
537
+ # Handle TypedHash (T::Hash[...])
538
+ if type_object.is_a?(T::Types::TypedHash)
539
+ return {}
540
+ end
541
+
542
+ # Handle simple types
543
+ case type_object
544
+ when T::Types::Simple
545
+ raw_type = type_object.raw_type
546
+ case raw_type.to_s
547
+ when 'String' then ''
548
+ when 'Integer' then 0
549
+ when 'Float' then 0.0
550
+ when 'TrueClass', 'FalseClass' then false
551
+ else
552
+ # For T::Struct types, return nil as fallback
553
+ nil
554
+ end
555
+ when T::Types::Union
556
+ # For unions, return nil (assuming it's nilable) or first non-nil default
557
+ nil
558
+ else
559
+ # Default fallback for unknown types
560
+ nil
561
+ end
562
+ end
563
+
505
564
  # Tool execution method
506
565
  sig { params(action: String, action_input: T.untyped).returns(String) }
507
566
  def execute_action(action, action_input)
@@ -133,7 +133,7 @@ module DSPy
133
133
  required = []
134
134
 
135
135
  @input_field_descriptors&.each do |name, descriptor|
136
- schema = type_to_json_schema(descriptor.type)
136
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
137
137
  schema[:description] = descriptor.description if descriptor.description
138
138
  properties[name] = schema
139
139
  required << name.to_s unless descriptor.has_default
@@ -160,7 +160,7 @@ module DSPy
160
160
  required = []
161
161
 
162
162
  @output_field_descriptors&.each do |name, descriptor|
163
- schema = type_to_json_schema(descriptor.type)
163
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
164
164
  schema[:description] = descriptor.description if descriptor.description
165
165
  properties[name] = schema
166
166
  required << name.to_s unless descriptor.has_default
@@ -178,255 +178,6 @@ module DSPy
178
178
  def output_schema
179
179
  @output_struct_class
180
180
  end
181
-
182
- private
183
-
184
- sig { params(type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
185
- def type_to_json_schema(type)
186
- # Handle T::Boolean type alias first
187
- if type == T::Boolean
188
- return { type: "boolean" }
189
- end
190
-
191
- # Handle type aliases by resolving to their underlying type
192
- if type.is_a?(T::Private::Types::TypeAlias)
193
- return type_to_json_schema(type.aliased_type)
194
- end
195
-
196
- # Handle raw class types first
197
- if type.is_a?(Class)
198
- if type < T::Enum
199
- # Get all enum values
200
- values = type.values.map(&:serialize)
201
- { type: "string", enum: values }
202
- elsif type == String
203
- { type: "string" }
204
- elsif type == Integer
205
- { type: "integer" }
206
- elsif type == Float
207
- { type: "number" }
208
- elsif type == Numeric
209
- { type: "number" }
210
- elsif type == Date
211
- { type: "string", format: "date" }
212
- elsif type == DateTime
213
- { type: "string", format: "date-time" }
214
- elsif type == Time
215
- { type: "string", format: "date-time" }
216
- elsif [TrueClass, FalseClass].include?(type)
217
- { type: "boolean" }
218
- elsif type < T::Struct
219
- # Handle custom T::Struct classes by generating nested object schema
220
- generate_struct_schema(type)
221
- else
222
- { type: "string" } # Default fallback
223
- end
224
- elsif type.is_a?(T::Types::Simple)
225
- case type.raw_type.to_s
226
- when "String"
227
- { type: "string" }
228
- when "Integer"
229
- { type: "integer" }
230
- when "Float"
231
- { type: "number" }
232
- when "Numeric"
233
- { type: "number" }
234
- when "Date"
235
- { type: "string", format: "date" }
236
- when "DateTime"
237
- { type: "string", format: "date-time" }
238
- when "Time"
239
- { type: "string", format: "date-time" }
240
- when "TrueClass", "FalseClass"
241
- { type: "boolean" }
242
- when "T::Boolean"
243
- { type: "boolean" }
244
- else
245
- # Check if it's an enum
246
- if type.raw_type < T::Enum
247
- # Get all enum values
248
- values = type.raw_type.values.map(&:serialize)
249
- { type: "string", enum: values }
250
- elsif type.raw_type < T::Struct
251
- # Handle custom T::Struct classes
252
- generate_struct_schema(type.raw_type)
253
- else
254
- { type: "string" } # Default fallback
255
- end
256
- end
257
- elsif type.is_a?(T::Types::TypedArray)
258
- # Handle arrays properly with nested item type
259
- {
260
- type: "array",
261
- items: type_to_json_schema(type.type)
262
- }
263
- elsif type.is_a?(T::Types::TypedHash)
264
- # Handle hashes as objects with additionalProperties
265
- # TypedHash has keys and values methods to access its key and value types
266
- key_schema = type_to_json_schema(type.keys)
267
- value_schema = type_to_json_schema(type.values)
268
-
269
- # Create a more descriptive schema for nested structures
270
- {
271
- type: "object",
272
- propertyNames: key_schema, # Describe key constraints
273
- additionalProperties: value_schema,
274
- # Add a more explicit description of the expected structure
275
- description: "A mapping where keys are #{key_schema[:type]}s and values are #{value_schema[:description] || value_schema[:type]}s"
276
- }
277
- elsif type.is_a?(T::Types::FixedHash)
278
- # Handle fixed hashes (from type aliases like { "key" => Type })
279
- properties = {}
280
- required = []
281
-
282
- type.types.each do |key, value_type|
283
- properties[key] = type_to_json_schema(value_type)
284
- required << key
285
- end
286
-
287
- {
288
- type: "object",
289
- properties: properties,
290
- required: required,
291
- additionalProperties: false
292
- }
293
- elsif type.class.name == "T::Private::Types::SimplePairUnion"
294
- # Handle T.nilable types (T::Private::Types::SimplePairUnion)
295
- # This is the actual implementation of T.nilable(SomeType)
296
- has_nil = type.respond_to?(:types) && type.types.any? do |t|
297
- (t.respond_to?(:raw_type) && t.raw_type == NilClass) ||
298
- (t.respond_to?(:name) && t.name == "NilClass")
299
- end
300
-
301
- if has_nil
302
- # Find the non-nil type
303
- non_nil_type = type.types.find do |t|
304
- !(t.respond_to?(:raw_type) && t.raw_type == NilClass) &&
305
- !(t.respond_to?(:name) && t.name == "NilClass")
306
- end
307
-
308
- if non_nil_type
309
- base_schema = type_to_json_schema(non_nil_type)
310
- if base_schema[:type].is_a?(String)
311
- # Convert single type to array with null
312
- { type: [base_schema[:type], "null"] }.merge(base_schema.except(:type))
313
- else
314
- # For complex schemas, use anyOf to allow null
315
- { anyOf: [base_schema, { type: "null" }] }
316
- end
317
- else
318
- { type: "string" } # Fallback
319
- end
320
- else
321
- # Not nilable SimplePairUnion - this is a regular T.any() union
322
- # Generate oneOf schema for all types
323
- if type.respond_to?(:types) && type.types.length > 1
324
- {
325
- oneOf: type.types.map { |t| type_to_json_schema(t) },
326
- description: "Union of multiple types"
327
- }
328
- else
329
- # Single type or fallback
330
- first_type = type.respond_to?(:types) ? type.types.first : type
331
- type_to_json_schema(first_type)
332
- end
333
- end
334
- elsif type.is_a?(T::Types::Union)
335
- # Check if this is a nilable type (contains NilClass)
336
- is_nilable = type.types.any? { |t| t == T::Utils.coerce(NilClass) }
337
- non_nil_types = type.types.reject { |t| t == T::Utils.coerce(NilClass) }
338
-
339
- # Special case: check if we have TrueClass + FalseClass (T.nilable(T::Boolean))
340
- if non_nil_types.size == 2 && is_nilable
341
- true_class_type = non_nil_types.find { |t| t.respond_to?(:raw_type) && t.raw_type == TrueClass }
342
- false_class_type = non_nil_types.find { |t| t.respond_to?(:raw_type) && t.raw_type == FalseClass }
343
-
344
- if true_class_type && false_class_type
345
- # This is T.nilable(T::Boolean) - treat as nilable boolean
346
- return { type: ["boolean", "null"] }
347
- end
348
- end
349
-
350
- if non_nil_types.size == 1 && is_nilable
351
- # This is T.nilable(SomeType) - generate proper schema with null allowed
352
- base_schema = type_to_json_schema(non_nil_types.first)
353
- if base_schema[:type].is_a?(String)
354
- # Convert single type to array with null
355
- { type: [base_schema[:type], "null"] }.merge(base_schema.except(:type))
356
- else
357
- # For complex schemas, use anyOf to allow null
358
- { anyOf: [base_schema, { type: "null" }] }
359
- end
360
- elsif non_nil_types.size == 1
361
- # Non-nilable single type union (shouldn't happen in practice)
362
- type_to_json_schema(non_nil_types.first)
363
- elsif non_nil_types.size > 1
364
- # Handle complex unions with oneOf for better JSON schema compliance
365
- base_schema = {
366
- oneOf: non_nil_types.map { |t| type_to_json_schema(t) },
367
- description: "Union of multiple types"
368
- }
369
- if is_nilable
370
- # Add null as an option for complex nilable unions
371
- base_schema[:oneOf] << { type: "null" }
372
- end
373
- base_schema
374
- else
375
- { type: "string" } # Fallback for complex unions
376
- end
377
- elsif type.is_a?(T::Types::ClassOf)
378
- # Handle T.class_of() types
379
- {
380
- type: "string",
381
- description: "Class name (T.class_of type)"
382
- }
383
- else
384
- { type: "string" } # Default fallback
385
- end
386
- end
387
-
388
- private
389
-
390
- # Generate JSON schema for custom T::Struct classes
391
- sig { params(struct_class: T.class_of(T::Struct)).returns(T::Hash[Symbol, T.untyped]) }
392
- def generate_struct_schema(struct_class)
393
- return { type: "string", description: "Struct (schema introspection not available)" } unless struct_class.respond_to?(:props)
394
-
395
- properties = {}
396
- required = []
397
-
398
- # Check if struct already has a _type field
399
- if struct_class.props.key?(:_type)
400
- raise DSPy::ValidationError, "_type field conflict: #{struct_class.name} already has a _type field defined. " \
401
- "DSPy uses _type for automatic type detection in union types."
402
- end
403
-
404
- # Add automatic _type field for type detection
405
- properties[:_type] = {
406
- type: "string",
407
- const: struct_class.name.split('::').last # Use the simple class name
408
- }
409
- required << "_type"
410
-
411
- struct_class.props.each do |prop_name, prop_info|
412
- prop_type = prop_info[:type_object] || prop_info[:type]
413
- properties[prop_name] = type_to_json_schema(prop_type)
414
-
415
- # A field is required if it's not fully optional
416
- # fully_optional is true for nilable prop fields
417
- # immutable const fields are required unless nilable
418
- unless prop_info[:fully_optional]
419
- required << prop_name.to_s
420
- end
421
- end
422
-
423
- {
424
- type: "object",
425
- properties: properties,
426
- required: required,
427
- description: "#{struct_class.name} struct"
428
- }
429
- end
430
181
  end
431
182
  end
432
183
  end
@@ -12,8 +12,10 @@ module DSPy
12
12
  extend T::Helpers
13
13
 
14
14
  # Convert a Sorbet type to JSON Schema format
15
- sig { params(type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
16
- def self.type_to_json_schema(type)
15
+ sig { params(type: T.untyped, visited: T.nilable(T::Set[T.untyped])).returns(T::Hash[Symbol, T.untyped]) }
16
+ def self.type_to_json_schema(type, visited = nil)
17
+ visited ||= Set.new
18
+
17
19
  # Handle T::Boolean type alias first
18
20
  if type == T::Boolean
19
21
  return { type: "boolean" }
@@ -21,7 +23,7 @@ module DSPy
21
23
 
22
24
  # Handle type aliases by resolving to their underlying type
23
25
  if type.is_a?(T::Private::Types::TypeAlias)
24
- return self.type_to_json_schema(type.aliased_type)
26
+ return self.type_to_json_schema(type.aliased_type, visited)
25
27
  end
26
28
 
27
29
  # Handle raw class types first
@@ -38,11 +40,26 @@ module DSPy
38
40
  { type: "number" }
39
41
  elsif type == Numeric
40
42
  { type: "number" }
43
+ elsif type == Date
44
+ { type: "string", format: "date" }
45
+ elsif type == DateTime
46
+ { type: "string", format: "date-time" }
47
+ elsif type == Time
48
+ { type: "string", format: "date-time" }
41
49
  elsif [TrueClass, FalseClass].include?(type)
42
50
  { type: "boolean" }
43
51
  elsif type < T::Struct
44
52
  # Handle custom T::Struct classes by generating nested object schema
45
- self.generate_struct_schema(type)
53
+ # Check for recursion
54
+ if visited.include?(type)
55
+ # Return a reference to avoid infinite recursion
56
+ {
57
+ "$ref" => "#/definitions/#{type.name.split('::').last}",
58
+ description: "Recursive reference to #{type.name}"
59
+ }
60
+ else
61
+ self.generate_struct_schema(type, visited)
62
+ end
46
63
  else
47
64
  { type: "string" } # Default fallback
48
65
  end
@@ -56,6 +73,12 @@ module DSPy
56
73
  { type: "number" }
57
74
  when "Numeric"
58
75
  { type: "number" }
76
+ when "Date"
77
+ { type: "string", format: "date" }
78
+ when "DateTime"
79
+ { type: "string", format: "date-time" }
80
+ when "Time"
81
+ { type: "string", format: "date-time" }
59
82
  when "TrueClass", "FalseClass"
60
83
  { type: "boolean" }
61
84
  when "T::Boolean"
@@ -68,7 +91,14 @@ module DSPy
68
91
  { type: "string", enum: values }
69
92
  elsif type.raw_type < T::Struct
70
93
  # Handle custom T::Struct classes
71
- generate_struct_schema(type.raw_type)
94
+ if visited.include?(type.raw_type)
95
+ {
96
+ "$ref" => "#/definitions/#{type.raw_type.name.split('::').last}",
97
+ description: "Recursive reference to #{type.raw_type.name}"
98
+ }
99
+ else
100
+ generate_struct_schema(type.raw_type, visited)
101
+ end
72
102
  else
73
103
  { type: "string" } # Default fallback
74
104
  end
@@ -77,13 +107,13 @@ module DSPy
77
107
  # Handle arrays properly with nested item type
78
108
  {
79
109
  type: "array",
80
- items: self.type_to_json_schema(type.type)
110
+ items: self.type_to_json_schema(type.type, visited)
81
111
  }
82
112
  elsif type.is_a?(T::Types::TypedHash)
83
113
  # Handle hashes as objects with additionalProperties
84
114
  # TypedHash has keys and values methods to access its key and value types
85
- key_schema = self.type_to_json_schema(type.keys)
86
- value_schema = self.type_to_json_schema(type.values)
115
+ key_schema = self.type_to_json_schema(type.keys, visited)
116
+ value_schema = self.type_to_json_schema(type.values, visited)
87
117
 
88
118
  # Create a more descriptive schema for nested structures
89
119
  {
@@ -99,7 +129,7 @@ module DSPy
99
129
  required = []
100
130
 
101
131
  type.types.each do |key, value_type|
102
- properties[key] = self.type_to_json_schema(value_type)
132
+ properties[key] = self.type_to_json_schema(value_type, visited)
103
133
  required << key
104
134
  end
105
135
 
@@ -125,7 +155,7 @@ module DSPy
125
155
  end
126
156
 
127
157
  if non_nil_type
128
- base_schema = self.type_to_json_schema(non_nil_type)
158
+ base_schema = self.type_to_json_schema(non_nil_type, visited)
129
159
  if base_schema[:type].is_a?(String)
130
160
  # Convert single type to array with null
131
161
  { type: [base_schema[:type], "null"] }.merge(base_schema.except(:type))
@@ -141,13 +171,13 @@ module DSPy
141
171
  # Generate oneOf schema for all types
142
172
  if type.respond_to?(:types) && type.types.length > 1
143
173
  {
144
- oneOf: type.types.map { |t| self.type_to_json_schema(t) },
174
+ oneOf: type.types.map { |t| self.type_to_json_schema(t, visited) },
145
175
  description: "Union of multiple types"
146
176
  }
147
177
  else
148
178
  # Single type or fallback
149
179
  first_type = type.respond_to?(:types) ? type.types.first : type
150
- self.type_to_json_schema(first_type)
180
+ self.type_to_json_schema(first_type, visited)
151
181
  end
152
182
  end
153
183
  elsif type.is_a?(T::Types::Union)
@@ -168,7 +198,7 @@ module DSPy
168
198
 
169
199
  if non_nil_types.size == 1 && is_nilable
170
200
  # This is T.nilable(SomeType) - generate proper schema with null allowed
171
- base_schema = self.type_to_json_schema(non_nil_types.first)
201
+ base_schema = self.type_to_json_schema(non_nil_types.first, visited)
172
202
  if base_schema[:type].is_a?(String)
173
203
  # Convert single type to array with null
174
204
  { type: [base_schema[:type], "null"] }.merge(base_schema.except(:type))
@@ -178,11 +208,11 @@ module DSPy
178
208
  end
179
209
  elsif non_nil_types.size == 1
180
210
  # Non-nilable single type union (shouldn't happen in practice)
181
- self.type_to_json_schema(non_nil_types.first)
211
+ self.type_to_json_schema(non_nil_types.first, visited)
182
212
  elsif non_nil_types.size > 1
183
213
  # Handle complex unions with oneOf for better JSON schema compliance
184
214
  base_schema = {
185
- oneOf: non_nil_types.map { |t| self.type_to_json_schema(t) },
215
+ oneOf: non_nil_types.map { |t| self.type_to_json_schema(t, visited) },
186
216
  description: "Union of multiple types"
187
217
  }
188
218
  if is_nilable
@@ -205,10 +235,15 @@ module DSPy
205
235
  end
206
236
 
207
237
  # Generate JSON schema for custom T::Struct classes
208
- sig { params(struct_class: T.class_of(T::Struct)).returns(T::Hash[Symbol, T.untyped]) }
209
- def self.generate_struct_schema(struct_class)
238
+ sig { params(struct_class: T.class_of(T::Struct), visited: T.nilable(T::Set[T.untyped])).returns(T::Hash[Symbol, T.untyped]) }
239
+ def self.generate_struct_schema(struct_class, visited = nil)
240
+ visited ||= Set.new
241
+
210
242
  return { type: "string", description: "Struct (schema introspection not available)" } unless struct_class.respond_to?(:props)
211
243
 
244
+ # Add this struct to visited set to detect recursion
245
+ visited.add(struct_class)
246
+
212
247
  properties = {}
213
248
  required = []
214
249
 
@@ -227,7 +262,7 @@ module DSPy
227
262
 
228
263
  struct_class.props.each do |prop_name, prop_info|
229
264
  prop_type = prop_info[:type_object] || prop_info[:type]
230
- properties[prop_name] = self.type_to_json_schema(prop_type)
265
+ properties[prop_name] = self.type_to_json_schema(prop_type, visited)
231
266
 
232
267
  # A field is required if it's not fully optional
233
268
  # fully_optional is true for nilable prop fields
@@ -237,6 +272,9 @@ module DSPy
237
272
  end
238
273
  end
239
274
 
275
+ # Remove this struct from visited set after processing
276
+ visited.delete(struct_class)
277
+
240
278
  {
241
279
  type: "object",
242
280
  properties: properties,
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.27.4"
4
+ VERSION = "0.27.6"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.4
4
+ version: 0.27.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-25 00:00:00.000000000 Z
10
+ date: 2025-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-configurable