dspy 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/dspy/module.rb CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
+ require 'securerandom'
5
6
  require_relative 'context'
6
7
  require_relative 'callbacks'
8
+ require_relative 'type_serializer'
9
+ require 'json'
7
10
 
8
11
  module DSPy
9
12
  class Module
@@ -12,10 +15,84 @@ module DSPy
12
15
  include Dry::Configurable
13
16
  include DSPy::Callbacks
14
17
 
18
+ class SubcriptionScope < T::Enum
19
+ enums do
20
+ Descendants = new('descendants')
21
+ SelfOnly = new('self')
22
+ end
23
+ end
24
+
25
+ DEFAULT_MODULE_SUBSCRIPTION_SCOPE = SubcriptionScope::Descendants
26
+
27
+ module ForwardOverrideHooks
28
+ def method_added(method_name)
29
+ super
30
+
31
+ return unless method_name == :forward
32
+ return if self == DSPy::Module
33
+ return if @_wrapping_forward
34
+
35
+ @_wrapping_forward = true
36
+
37
+ original = instance_method(:forward)
38
+ define_method(:forward) do |*args, **kwargs, &block|
39
+ instrument_forward_call(args, kwargs) do
40
+ original.bind(self).call(*args, **kwargs, &block)
41
+ end
42
+ end
43
+ ensure
44
+ @_wrapping_forward = false
45
+ end
46
+ end
47
+
48
+ class << self
49
+ def inherited(subclass)
50
+ super
51
+ specs_copy = module_subscription_specs.map(&:dup)
52
+ subclass.instance_variable_set(:@module_subscription_specs, specs_copy)
53
+ subclass.extend(ForwardOverrideHooks)
54
+ end
55
+
56
+ def subscribe(pattern, handler = nil, scope: DEFAULT_MODULE_SUBSCRIPTION_SCOPE, &block)
57
+ scope = normalize_scope(scope)
58
+ raise ArgumentError, 'Provide a handler method or block' if handler.nil? && block.nil?
59
+
60
+ module_subscription_specs << {
61
+ pattern: pattern,
62
+ handler: handler,
63
+ block: block,
64
+ scope: scope
65
+ }
66
+ end
67
+
68
+ def module_subscription_specs
69
+ @module_subscription_specs ||= []
70
+ end
71
+
72
+ private
73
+
74
+ def validate_subscription_scope!(scope)
75
+ T.must(scope)
76
+ end
77
+
78
+ def normalize_scope(scope)
79
+ return scope if scope.is_a?(SubcriptionScope)
80
+
81
+ case scope
82
+ when :descendants
83
+ SubcriptionScope::Descendants
84
+ when :self
85
+ SubcriptionScope::SelfOnly
86
+ else
87
+ raise ArgumentError, "Unsupported subscription scope: #{scope.inspect}"
88
+ end
89
+ end
90
+ end
91
+
15
92
  # Per-instance LM configuration
16
93
  setting :lm, default: nil
17
94
 
18
- # Define callback hooks for forward method
95
+ # Enable callback hooks for forward method
19
96
  create_before_callback :forward
20
97
  create_after_callback :forward
21
98
  create_around_callback :forward
@@ -29,23 +106,8 @@ module DSPy
29
106
  .returns(T.type_parameter(:O))
30
107
  end
31
108
  def forward(**input_values)
32
- # Create span for this module's execution
33
- observation_type = DSPy::ObservationType.for_module_class(self.class)
34
- DSPy::Context.with_span(
35
- operation: "#{self.class.name}.forward",
36
- **observation_type.langfuse_attributes,
37
- 'langfuse.observation.input' => input_values.to_json,
38
- 'dspy.module' => self.class.name
39
- ) do |span|
109
+ instrument_forward_call([], input_values) do
40
110
  result = forward_untyped(**input_values)
41
-
42
- # Add output to span
43
- if span && result
44
- output_json = result.respond_to?(:to_h) ? result.to_h.to_json : result.to_json rescue result.to_s
45
- span.set_attribute('langfuse.observation.output', output_json)
46
- end
47
-
48
- # Cast the result of forward_untyped to the expected output type
49
111
  T.cast(result, T.type_parameter(:O))
50
112
  end
51
113
  end
@@ -116,5 +178,139 @@ module DSPy
116
178
  def predictors
117
179
  named_predictors.map { |(_, predictor)| predictor }
118
180
  end
181
+
182
+ def instrument_forward_call(call_args, call_kwargs)
183
+ ensure_module_subscriptions!
184
+
185
+ DSPy::Context.with_module(self) do
186
+ observation_type = DSPy::ObservationType.for_module_class(self.class)
187
+ span_attributes = observation_type.langfuse_attributes.merge(
188
+ 'langfuse.observation.input' => serialize_module_input(call_args, call_kwargs),
189
+ 'dspy.module' => self.class.name
190
+ )
191
+
192
+ DSPy::Context.with_span(
193
+ operation: "#{self.class.name}.forward",
194
+ **span_attributes
195
+ ) do |span|
196
+ yield.tap do |result|
197
+ if span && result
198
+ span.set_attribute('langfuse.observation.output', serialize_module_output(result))
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ def serialize_module_input(call_args, call_kwargs)
206
+ payload = if call_kwargs && !call_kwargs.empty?
207
+ call_kwargs
208
+ elsif call_args && !call_args.empty?
209
+ call_args
210
+ else
211
+ {}
212
+ end
213
+
214
+ serialized = DSPy::TypeSerializer.serialize(payload)
215
+ JSON.generate(serialized)
216
+ rescue StandardError
217
+ payload.to_s
218
+ end
219
+
220
+ def serialize_module_output(result)
221
+ serialized = DSPy::TypeSerializer.serialize(result)
222
+ JSON.generate(serialized)
223
+ rescue StandardError
224
+ result.to_s
225
+ end
226
+
227
+ private :instrument_forward_call, :serialize_module_input, :serialize_module_output
228
+
229
+ sig { returns(String) }
230
+ def module_scope_id
231
+ @module_scope_id ||= SecureRandom.uuid
232
+ end
233
+
234
+ sig { returns(T.nilable(String)) }
235
+ def module_scope_label
236
+ @module_scope_label
237
+ end
238
+
239
+ sig { params(label: T.nilable(String)).void }
240
+ def module_scope_label=(label)
241
+ @module_scope_label = label
242
+ end
243
+
244
+ sig { returns(T::Array[String]) }
245
+ def registered_module_subscriptions
246
+ Array(@module_subscription_ids).dup
247
+ end
248
+
249
+ sig { void }
250
+ def unsubscribe_module_events
251
+ Array(@module_subscription_ids).each { |id| DSPy.events.unsubscribe(id) }
252
+ @module_subscription_ids = []
253
+ @module_subscriptions_registered = false
254
+ end
255
+
256
+ private
257
+
258
+ def ensure_module_subscriptions!
259
+ return if @module_subscriptions_registered
260
+
261
+ specs = self.class.module_subscription_specs
262
+ if specs.empty?
263
+ @module_subscriptions_registered = true
264
+ return
265
+ end
266
+
267
+ @module_subscription_ids ||= []
268
+ specs.each do |spec|
269
+ callback = build_subscription_callback(spec)
270
+ subscription_id = DSPy.events.subscribe(spec[:pattern], &callback)
271
+ @module_subscription_ids << subscription_id
272
+ end
273
+
274
+ @module_subscriptions_registered = true
275
+ end
276
+
277
+ def build_subscription_callback(spec)
278
+ scope = spec[:scope] || DEFAULT_MODULE_SUBSCRIPTION_SCOPE
279
+ handler = spec[:handler]
280
+ block = spec[:block]
281
+
282
+ proc do |event_name, attributes|
283
+ next unless module_event_within_scope?(attributes, scope)
284
+
285
+ if handler
286
+ send(handler, event_name, attributes)
287
+ else
288
+ instance_exec(event_name, attributes, &block)
289
+ end
290
+ end
291
+ end
292
+
293
+ def module_event_within_scope?(attributes, scope)
294
+ metadata = extract_module_metadata(attributes)
295
+ return false unless metadata
296
+
297
+ case scope
298
+ when SubcriptionScope::SelfOnly
299
+ metadata[:leaf_id] == module_scope_id
300
+ else
301
+ metadata[:path_ids].include?(module_scope_id)
302
+ end
303
+ end
304
+
305
+ def extract_module_metadata(attributes)
306
+ path = attributes[:module_path] || attributes['module_path']
307
+ leaf = attributes[:module_leaf] || attributes['module_leaf']
308
+ return nil unless path.is_a?(Array)
309
+
310
+ {
311
+ path_ids: path.map { |entry| entry[:id] || entry['id'] }.compact,
312
+ leaf_id: leaf&.dig(:id) || leaf&.dig('id')
313
+ }
314
+ end
119
315
  end
120
316
  end
data/lib/dspy/prompt.rb CHANGED
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'sorbet-runtime'
5
+ require 'sorbet/toon'
6
+
4
7
  require_relative 'few_shot_example'
8
+ require_relative 'schema/sorbet_toon_adapter'
5
9
 
6
10
  module DSPy
7
11
  class Prompt
@@ -33,6 +37,15 @@ module DSPy
33
37
  DSPy.config.lm&.schema_format || @schema_format || :json
34
38
  end
35
39
 
40
+ sig { returns(Symbol) }
41
+ def data_format
42
+ return @data_format if @data_format && @data_format != :json
43
+
44
+ lm = DSPy.config.lm
45
+ lm_format = lm&.respond_to?(:data_format) ? lm.data_format : nil
46
+ lm_format || @data_format || :json
47
+ end
48
+
36
49
  sig { returns(T.nilable(T.class_of(Signature))) }
37
50
  attr_reader :signature_class
38
51
 
@@ -44,10 +57,11 @@ module DSPy
44
57
  few_shot_examples: T::Array[FewShotExample],
45
58
  signature_class_name: T.nilable(String),
46
59
  schema_format: Symbol,
47
- signature_class: T.nilable(T.class_of(Signature))
60
+ signature_class: T.nilable(T.class_of(Signature)),
61
+ data_format: Symbol
48
62
  ).void
49
63
  end
50
- def initialize(instruction:, input_schema:, output_schema:, few_shot_examples: [], signature_class_name: nil, schema_format: :json, signature_class: nil)
64
+ def initialize(instruction:, input_schema:, output_schema:, few_shot_examples: [], signature_class_name: nil, schema_format: :json, signature_class: nil, data_format: :json)
51
65
  @instruction = instruction
52
66
  @few_shot_examples = few_shot_examples.freeze
53
67
  @input_schema = input_schema.freeze
@@ -55,6 +69,7 @@ module DSPy
55
69
  @signature_class_name = signature_class_name
56
70
  @schema_format = schema_format
57
71
  @signature_class = signature_class
72
+ @data_format = data_format
58
73
  end
59
74
 
60
75
  # Immutable update methods for optimization
@@ -67,7 +82,8 @@ module DSPy
67
82
  few_shot_examples: @few_shot_examples,
68
83
  signature_class_name: @signature_class_name,
69
84
  schema_format: @schema_format,
70
- signature_class: @signature_class
85
+ signature_class: @signature_class,
86
+ data_format: @data_format
71
87
  )
72
88
  end
73
89
 
@@ -80,7 +96,8 @@ module DSPy
80
96
  few_shot_examples: new_examples,
81
97
  signature_class_name: @signature_class_name,
82
98
  schema_format: @schema_format,
83
- signature_class: @signature_class
99
+ signature_class: @signature_class,
100
+ data_format: @data_format
84
101
  )
85
102
  end
86
103
 
@@ -106,7 +123,13 @@ module DSPy
106
123
  sections << "```baml"
107
124
  sections << render_baml_schema(@output_schema, :output)
108
125
  sections << "```"
109
- else # :json (default)
126
+ when :toon
127
+ sections << "Your input schema fields (TOON order) are:"
128
+ sections << Sorbet::Toon::SignatureFormatter.describe_signature(@signature_class, :input)
129
+ sections << ""
130
+ sections << "Your output schema fields (TOON order) are:"
131
+ sections << Sorbet::Toon::SignatureFormatter.describe_signature(@signature_class, :output)
132
+ else
110
133
  sections << "Your input schema fields are:"
111
134
  sections << "```json"
112
135
  sections << JSON.pretty_generate(@input_schema)
@@ -117,11 +140,10 @@ module DSPy
117
140
  sections << JSON.pretty_generate(@output_schema)
118
141
  sections << "```"
119
142
  end
120
-
143
+
121
144
  sections << ""
122
145
  sections << "All interactions will be structured in the following way, with the appropriate values filled in."
123
-
124
- # Add few-shot examples if present
146
+
125
147
  if @few_shot_examples.any?
126
148
  sections << ""
127
149
  sections << "Here are some examples:"
@@ -133,36 +155,59 @@ module DSPy
133
155
  end
134
156
  end
135
157
 
136
- sections << "## Input values"
137
- sections << "```json"
138
- sections << "{input_values}"
139
- sections << "```"
140
-
141
- sections << "## Output values"
142
- sections << "Respond exclusively with the output schema fields in the json block below."
143
- sections << "```json"
144
- sections << "{output_values}"
145
- sections << "```"
146
-
158
+ if data_format == :toon && @signature_class
159
+ sections << "## Input values"
160
+ sections << "```toon"
161
+ sections << "{input_values}"
162
+ sections << "```"
163
+ sections << ""
164
+ sections << "## Output values"
165
+ sections << "Respond exclusively with a ```toon``` block containing only the output fields defined above, in the same order."
166
+ sections << "```toon"
167
+ sections << "{output_values}"
168
+ sections << "```"
169
+ else
170
+ sections << "## Input values"
171
+ sections << "```json"
172
+ sections << "{input_values}"
173
+ sections << "```"
174
+
175
+ sections << "## Output values"
176
+ sections << "Respond exclusively with the output schema fields in the json block below."
177
+ sections << "```json"
178
+ sections << "{output_values}"
179
+ sections << "```"
180
+ end
181
+
147
182
  sections << ""
148
183
  sections << "In adhering to this structure, your objective is: #{@instruction}"
149
-
184
+
150
185
  sections.join("\n")
151
186
  end
152
187
 
153
188
  sig { params(input_values: T::Hash[Symbol, T.untyped]).returns(String) }
154
189
  def render_user_prompt(input_values)
155
190
  sections = []
156
-
157
- sections << "## Input Values"
158
- sections << "```json"
159
- sections << JSON.pretty_generate(serialize_for_json(input_values))
160
- sections << "```"
161
-
162
- sections << ""
163
- sections << "Respond with the corresponding output schema fields wrapped in a ```json ``` block,"
164
- sections << "starting with the heading `## Output values`."
165
-
191
+
192
+ if data_format == :toon && @signature_class
193
+ toon_payload = DSPy::Schema::SorbetToonAdapter.render_input(@signature_class, input_values)
194
+
195
+ sections << "## Input Values"
196
+ sections << "```toon"
197
+ sections << toon_payload
198
+ sections << "```"
199
+ sections << ""
200
+ sections << "Respond with the corresponding output schema fields encoded as TOON inside a ```toon``` block starting with the heading `## Output values`."
201
+ else
202
+ sections << "## Input Values"
203
+ sections << "```json"
204
+ sections << JSON.pretty_generate(serialize_for_json(input_values))
205
+ sections << "```"
206
+ sections << ""
207
+ sections << "Respond with the corresponding output schema fields wrapped in a ```json ``` block,"
208
+ sections << "starting with the heading `## Output values`."
209
+ end
210
+
166
211
  sections.join("\n")
167
212
  end
168
213
 
@@ -184,7 +229,8 @@ module DSPy
184
229
  input_schema: @input_schema,
185
230
  output_schema: @output_schema,
186
231
  signature_class_name: @signature_class_name,
187
- schema_format: @schema_format
232
+ schema_format: @schema_format,
233
+ data_format: @data_format
188
234
  }
189
235
  end
190
236
 
@@ -198,13 +244,24 @@ module DSPy
198
244
  output_schema: hash[:output_schema] || {},
199
245
  few_shot_examples: examples,
200
246
  signature_class_name: hash[:signature_class_name],
201
- schema_format: hash[:schema_format] || :json
247
+ schema_format: hash[:schema_format] || :json,
248
+ data_format: hash[:data_format] || :json
202
249
  )
203
250
  end
204
251
 
205
252
  # Create prompt from signature class
206
- sig { params(signature_class: T.class_of(Signature), schema_format: Symbol).returns(Prompt) }
207
- def self.from_signature(signature_class, schema_format: :json)
253
+ sig do
254
+ params(
255
+ signature_class: T.class_of(Signature),
256
+ schema_format: T.nilable(Symbol),
257
+ data_format: T.nilable(Symbol)
258
+ ).returns(Prompt)
259
+ end
260
+ def self.from_signature(signature_class, schema_format: nil, data_format: nil)
261
+ lm = DSPy.config.lm
262
+ schema_format ||= lm&.schema_format || :json
263
+ data_format ||= (lm&.respond_to?(:data_format) ? lm.data_format : nil) || :json
264
+
208
265
  new(
209
266
  instruction: signature_class.description || "Complete this task.",
210
267
  input_schema: signature_class.input_json_schema,
@@ -212,7 +269,8 @@ module DSPy
212
269
  few_shot_examples: [],
213
270
  signature_class_name: signature_class.name,
214
271
  schema_format: schema_format,
215
- signature_class: signature_class
272
+ signature_class: signature_class,
273
+ data_format: data_format
216
274
  )
217
275
  end
218
276
 
@@ -336,4 +394,4 @@ module DSPy
336
394
  result
337
395
  end
338
396
  end
339
- end
397
+ end
data/lib/dspy/re_act.rb CHANGED
@@ -98,13 +98,14 @@ module DSPy
98
98
  @tools = T.let({}, T::Hash[String, T.untyped])
99
99
  tools.each { |tool| @tools[tool.name.downcase] = tool }
100
100
  @max_iterations = max_iterations
101
+ @data_format = T.let(DSPy.config.lm&.data_format || :json, Symbol)
101
102
 
102
103
  # Create dynamic ActionEnum class with tool names + finish
103
104
  @action_enum_class = create_action_enum_class
104
105
 
105
106
  # Create dynamic signature classes that include the original input fields
106
- thought_signature = create_thought_signature(signature_class)
107
- observation_signature = create_observation_signature(signature_class)
107
+ thought_signature = create_thought_signature(signature_class, @data_format)
108
+ observation_signature = create_observation_signature(signature_class, @data_format)
108
109
 
109
110
  # Create thought generator using Predict to preserve field descriptions
110
111
  @thought_generator = T.let(DSPy::Predict.new(thought_signature), DSPy::Predict)
@@ -216,6 +217,28 @@ module DSPy
216
217
  end
217
218
  end
218
219
 
220
+ sig { params(input_struct: T.untyped).returns(T.untyped) }
221
+ def format_input_context(input_struct)
222
+ return input_struct if toon_data_format?
223
+
224
+ DSPy::TypeSerializer.serialize(input_struct).to_json
225
+ end
226
+
227
+ sig { params(history: T::Array[HistoryEntry]).returns(T.untyped) }
228
+ def format_history(history)
229
+ toon_data_format? ? history : serialize_history_for_llm(history)
230
+ end
231
+
232
+ sig { params(observation: T.untyped).returns(T.untyped) }
233
+ def format_observation(observation)
234
+ toon_data_format? ? observation : serialize_for_llm(observation)
235
+ end
236
+
237
+ sig { returns(T::Boolean) }
238
+ def toon_data_format?
239
+ @data_format == :toon
240
+ end
241
+
219
242
  # Creates a dynamic ActionEnum class with tool names and "finish"
220
243
  sig { returns(T.class_of(T::Enum)) }
221
244
  def create_action_enum_class
@@ -241,9 +264,14 @@ module DSPy
241
264
  end
242
265
 
243
266
  # Creates a dynamic Thought signature that includes the original input fields
244
- sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(DSPy::Signature)) }
245
- def create_thought_signature(signature_class)
267
+ sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
268
+ def create_thought_signature(signature_class, data_format)
246
269
  action_enum_class = @action_enum_class
270
+ input_context_type = if data_format == :toon
271
+ signature_class.input_struct_class || String
272
+ else
273
+ String
274
+ end
247
275
  # Create new class that inherits from DSPy::Signature
248
276
  Class.new(DSPy::Signature) do
249
277
  # Set description
@@ -251,8 +279,8 @@ module DSPy
251
279
 
252
280
  # Define input fields
253
281
  input do
254
- const :input_context, String,
255
- description: "Serialized representation of all input fields"
282
+ const :input_context, input_context_type,
283
+ description: data_format == :toon ? "All original input fields with their typed values" : "Serialized representation of all input fields"
256
284
  const :history, T::Array[HistoryEntry],
257
285
  description: "Previous thoughts and actions, including observations from tools."
258
286
  const :available_tools, T::Array[AvailableTool],
@@ -272,8 +300,13 @@ module DSPy
272
300
  end
273
301
 
274
302
  # Creates a dynamic observation signature that includes the original input fields
275
- sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(DSPy::Signature)) }
276
- def create_observation_signature(signature_class)
303
+ sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
304
+ def create_observation_signature(signature_class, data_format)
305
+ input_context_type = if data_format == :toon
306
+ signature_class.input_struct_class || String
307
+ else
308
+ String
309
+ end
277
310
  # Create new class that inherits from DSPy::Signature
278
311
  Class.new(DSPy::Signature) do
279
312
  # Set description
@@ -281,8 +314,8 @@ module DSPy
281
314
 
282
315
  # Define input fields
283
316
  input do
284
- const :input_context, String,
285
- description: "Serialized representation of all input fields"
317
+ const :input_context, input_context_type,
318
+ description: data_format == :toon ? "All original input fields with their typed values" : "Serialized representation of all input fields"
286
319
  const :history, T::Array[HistoryEntry],
287
320
  description: "Previous thoughts, actions, and observations."
288
321
  const :observation, T.untyped,
@@ -358,8 +391,8 @@ module DSPy
358
391
  ) do
359
392
  # Generate thought and action
360
393
  thought_obj = @thought_generator.forward(
361
- input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
362
- history: serialize_history_for_llm(history),
394
+ input_context: format_input_context(input_struct),
395
+ history: format_history(history),
363
396
  available_tools: available_tools_desc
364
397
  )
365
398
 
@@ -617,9 +650,9 @@ module DSPy
617
650
  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]) }
618
651
  def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration)
619
652
  observation_result = @observation_processor.forward(
620
- input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
621
- history: serialize_history_for_llm(history),
622
- observation: serialize_for_llm(observation)
653
+ input_context: format_input_context(input_struct),
654
+ history: format_history(history),
655
+ observation: format_observation(observation)
623
656
  )
624
657
 
625
658
  return { should_finish: false } unless observation_result.next_step == NextStep::Finish
@@ -634,8 +667,8 @@ module DSPy
634
667
  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) }
635
668
  def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
636
669
  final_thought = @thought_generator.forward(
637
- input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
638
- history: serialize_history_for_llm(history),
670
+ input_context: format_input_context(input_struct),
671
+ history: format_history(history),
639
672
  available_tools: available_tools_desc
640
673
  )
641
674
 
@@ -254,10 +254,13 @@ module DSPy
254
254
  "DSPy uses _type for automatic type detection in union types."
255
255
  end
256
256
 
257
+ struct_name = struct_class.name || "Struct#{format('%x', struct_class.object_id)}"
258
+ simple_name = struct_name.split('::').last || struct_name
259
+
257
260
  # Add automatic _type field for type detection
258
261
  properties[:_type] = {
259
262
  type: "string",
260
- const: struct_class.name.split('::').last # Use the simple class name
263
+ const: simple_name # Use the simple class name
261
264
  }
262
265
  required << "_type"
263
266
 
@@ -280,7 +283,7 @@ module DSPy
280
283
  type: "object",
281
284
  properties: properties,
282
285
  required: required,
283
- description: "#{struct_class.name} struct"
286
+ description: "#{struct_name} struct"
284
287
  }
285
288
  end
286
289