dspy 0.27.0 → 0.27.2

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.
@@ -15,6 +15,13 @@ module DSPy
15
15
  public_key = ENV['LANGFUSE_PUBLIC_KEY']
16
16
  secret_key = ENV['LANGFUSE_SECRET_KEY']
17
17
 
18
+ # Skip OTLP configuration in test environment UNLESS Langfuse credentials are explicitly provided
19
+ # This allows observability tests to run while protecting general tests from network calls
20
+ if (ENV['RACK_ENV'] == 'test' || ENV['RAILS_ENV'] == 'test' || defined?(RSpec)) && !(public_key && secret_key)
21
+ DSPy.log('observability.disabled', reason: 'Test environment detected - OTLP disabled')
22
+ return
23
+ end
24
+
18
25
  unless public_key && secret_key
19
26
  return
20
27
  end
data/lib/dspy/predict.rb CHANGED
@@ -131,46 +131,32 @@ module DSPy
131
131
  with_prompt(@prompt.add_examples(examples))
132
132
  end
133
133
 
134
- sig { override.params(kwargs: T.untyped).returns(T.type_parameter(:O)) }
135
- def forward(**kwargs)
136
- @last_input_values = kwargs.clone
137
- T.cast(forward_untyped(**kwargs), T.type_parameter(:O))
138
- end
134
+ # Remove forward override to let Module#forward handle span creation
139
135
 
140
136
  sig { params(input_values: T.untyped).returns(T.untyped) }
141
137
  def forward_untyped(**input_values)
142
- # Wrap prediction in span tracking
143
- DSPy::Context.with_span(
144
- operation: "#{self.class.name}.forward",
145
- 'langfuse.observation.type' => 'span',
146
- 'langfuse.observation.input' => input_values.to_json,
147
- 'dspy.module' => self.class.name,
148
- 'dspy.signature' => @signature_class.name
149
- ) do |span|
150
- # Validate input
151
- validate_input_struct(input_values)
152
-
153
- # Check if LM is configured
154
- current_lm = lm
155
- if current_lm.nil?
156
- raise DSPy::ConfigurationError.missing_lm(self.class.name)
157
- end
158
-
159
- # Call LM and process response
160
- output_attributes = current_lm.chat(self, input_values)
161
- processed_output = process_lm_output(output_attributes)
162
-
163
- # Create combined result struct
164
- prediction_result = create_prediction_result(input_values, processed_output)
165
-
166
- # Add output to span
167
- if span && prediction_result
168
- output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
169
- span.set_attribute('langfuse.observation.output', DSPy::Utils::Serialization.to_json(output_hash))
170
- end
171
-
172
- prediction_result
138
+ # Module#forward handles span creation, we just do the prediction logic
139
+
140
+ # Store input values for optimization
141
+ @last_input_values = input_values.clone
142
+
143
+ # Validate input
144
+ validate_input_struct(input_values)
145
+
146
+ # Check if LM is configured
147
+ current_lm = lm
148
+ if current_lm.nil?
149
+ raise DSPy::ConfigurationError.missing_lm(self.class.name)
173
150
  end
151
+
152
+ # Call LM and process response
153
+ output_attributes = current_lm.chat(self, input_values)
154
+ processed_output = process_lm_output(output_attributes)
155
+
156
+ # Create combined result struct
157
+ prediction_result = create_prediction_result(input_values, processed_output)
158
+
159
+ prediction_result
174
160
  end
175
161
 
176
162
  private
data/lib/dspy/re_act.rb CHANGED
@@ -241,9 +241,10 @@ module DSPy
241
241
  # Executes a single iteration of the ReAct loop
242
242
  sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
243
243
  def execute_single_iteration(input_struct, history, available_tools_desc, iteration, tools_used, last_observation)
244
- # Track each iteration with span
244
+ # Track each iteration with agent span
245
245
  DSPy::Context.with_span(
246
246
  operation: 'react.iteration',
247
+ **DSPy::ObservationType::Agent.langfuse_attributes,
247
248
  'dspy.module' => 'ReAct',
248
249
  'react.iteration' => iteration,
249
250
  'react.max_iterations' => @max_iterations,
@@ -355,6 +356,7 @@ module DSPy
355
356
  if action && @tools[action.downcase]
356
357
  DSPy::Context.with_span(
357
358
  operation: 'react.tool_call',
359
+ **DSPy::ObservationType::Tool.langfuse_attributes,
358
360
  'dspy.module' => 'ReAct',
359
361
  'react.iteration' => iteration,
360
362
  'tool.name' => action.downcase,
@@ -419,7 +421,7 @@ module DSPy
419
421
 
420
422
  sig { params(iteration: Integer, thought: String, action: String, action_input: T.untyped, observation: String, tools_used: T::Array[String]).void }
421
423
  def emit_iteration_complete_event(iteration, thought, action, action_input, observation, tools_used)
422
- DSPy.log('react.iteration_complete', **{
424
+ DSPy.event('react.iteration_complete', {
423
425
  'react.iteration' => iteration,
424
426
  'react.thought' => thought,
425
427
  'react.action' => action,
@@ -432,7 +434,7 @@ module DSPy
432
434
  sig { params(iterations_count: Integer, final_answer: T.nilable(String), tools_used: T::Array[String], history: T::Array[HistoryEntry]).void }
433
435
  def handle_max_iterations_if_needed(iterations_count, final_answer, tools_used, history)
434
436
  if iterations_count >= @max_iterations && final_answer.nil?
435
- DSPy.log('react.max_iterations', **{
437
+ DSPy.event('react.max_iterations', {
436
438
  'react.iteration_count' => iterations_count,
437
439
  'react.max_iterations' => @max_iterations,
438
440
  'react.tools_used' => tools_used.uniq,
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'json'
5
+ require_relative '../type_system/sorbet_json_schema'
6
+ require_relative '../mixins/type_coercion'
5
7
 
6
8
  module DSPy
7
9
  module Tools
@@ -9,6 +11,7 @@ module DSPy
9
11
  class Base
10
12
  extend T::Sig
11
13
  extend T::Helpers
14
+ include DSPy::Mixins::TypeCoercion
12
15
 
13
16
  class << self
14
17
  extend T::Sig
@@ -30,14 +33,14 @@ module DSPy
30
33
 
31
34
  # Get the JSON schema for the call method based on its Sorbet signature
32
35
  sig { returns(T::Hash[Symbol, T.untyped]) }
33
- def call_schema
36
+ def call_schema_object
34
37
  method_obj = instance_method(:call)
35
38
  sig_info = T::Utils.signature_for_method(method_obj)
36
39
 
37
40
  if sig_info.nil?
38
41
  # Fallback for methods without signatures
39
42
  return {
40
- type: :object,
43
+ type: "object",
41
44
  properties: {},
42
45
  required: []
43
46
  }
@@ -50,10 +53,8 @@ module DSPy
50
53
  sig_info.arg_types.each do |param_name, param_type|
51
54
  next if param_name == :block # Skip block parameters
52
55
 
53
- properties[param_name] = {
54
- type: sorbet_type_to_json_schema(param_type)[:type],
55
- description: "Parameter #{param_name}"
56
- }
56
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
57
+ properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
57
58
 
58
59
  # Check if parameter is required (not nilable)
59
60
  unless param_type.class.name.include?('Union') && param_type.name.include?('NilClass')
@@ -65,10 +66,8 @@ module DSPy
65
66
  sig_info.kwarg_types.each do |param_name, param_type|
66
67
  next if param_name == :block # Skip block parameters
67
68
 
68
- properties[param_name] = {
69
- type: sorbet_type_to_json_schema(param_type)[:type],
70
- description: "Parameter #{param_name}"
71
- }
69
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
70
+ properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
72
71
 
73
72
  # Check if parameter is required by looking at required kwarg names
74
73
  if sig_info.req_kwarg_names.include?(param_name)
@@ -79,54 +78,25 @@ module DSPy
79
78
  end
80
79
 
81
80
  {
82
- type: :object,
81
+ type: "object",
83
82
  properties: properties,
84
83
  required: required
85
84
  }
86
85
  end
87
86
 
88
- private
89
-
90
- # Convert Sorbet types to JSON Schema types
91
- sig { params(sorbet_type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
92
- def sorbet_type_to_json_schema(sorbet_type)
93
- if sorbet_type.is_a?(T::Types::Simple)
94
- raw_type = sorbet_type.raw_type
95
-
96
- if raw_type == String
97
- { type: :string }
98
- elsif raw_type == Integer
99
- { type: :integer }
100
- elsif raw_type == Float
101
- { type: :number }
102
- elsif raw_type == Numeric
103
- { type: :number }
104
- elsif raw_type == TrueClass || raw_type == FalseClass
105
- { type: :boolean }
106
- elsif raw_type == T::Boolean
107
- { type: :boolean }
108
- else
109
- { type: :string, description: "#{raw_type} (converted to string)" }
110
- end
111
- elsif sorbet_type.is_a?(T::Types::Union)
112
- # Handle nilable types
113
- non_nil_types = sorbet_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
114
- if non_nil_types.length == 1
115
- result = sorbet_type_to_json_schema(non_nil_types.first)
116
- result[:description] = "#{result[:description] || ''} (optional)".strip
117
- result
118
- else
119
- { type: :string, description: "Union type (converted to string)" }
120
- end
121
- elsif sorbet_type.is_a?(T::Types::TypedArray)
122
- {
123
- type: :array,
124
- items: sorbet_type_to_json_schema(sorbet_type.type)
87
+ # Get the full tool schema for LLM tools format
88
+ sig { returns(T::Hash[Symbol, T.untyped]) }
89
+ def call_schema
90
+ {
91
+ type: 'function',
92
+ function: {
93
+ name: 'call',
94
+ description: "Call the #{self.name} tool",
95
+ parameters: call_schema_object
125
96
  }
126
- else
127
- { type: :string, description: "#{sorbet_type} (converted to string)" }
128
- end
97
+ }
129
98
  end
99
+
130
100
  end
131
101
 
132
102
  # Instance methods that tools can use
@@ -143,7 +113,7 @@ module DSPy
143
113
  # Get the JSON schema string for the tool, formatted for LLM consumption
144
114
  sig { returns(String) }
145
115
  def schema
146
- schema_obj = self.class.call_schema
116
+ schema_obj = self.class.call_schema_object
147
117
  tool_info = {
148
118
  name: name,
149
119
  description: description,
@@ -152,11 +122,17 @@ module DSPy
152
122
  JSON.generate(tool_info)
153
123
  end
154
124
 
125
+ # Get the full call schema compatible with LLM tools format
126
+ sig { returns(T::Hash[Symbol, T.untyped]) }
127
+ def call_schema
128
+ self.class.call_schema
129
+ end
130
+
155
131
  # Dynamic call method for ReAct agent - parses JSON arguments and calls the typed method
156
132
  sig { params(args_json: T.untyped).returns(T.untyped) }
157
133
  def dynamic_call(args_json)
158
134
  # Parse arguments based on the call schema
159
- schema = self.class.call_schema
135
+ schema = self.class.call_schema_object
160
136
 
161
137
  if schema[:properties].empty?
162
138
  # No parameters - call without arguments
@@ -178,12 +154,34 @@ module DSPy
178
154
 
179
155
  # Convert string keys to symbols and validate types
180
156
  kwargs = {}
181
- schema[:properties].each do |param_name, param_schema|
182
- key = param_name.to_s
183
- if args.key?(key)
184
- kwargs[param_name] = convert_argument_type(args[key], param_schema)
185
- elsif schema[:required].include?(key)
186
- return "Error: Missing required parameter: #{key}"
157
+
158
+ # Get method signature for type information
159
+ method_obj = self.class.instance_method(:call)
160
+ sig_info = T::Utils.signature_for_method(method_obj)
161
+
162
+ if sig_info
163
+ # Handle kwargs using type signature information
164
+ sig_info.kwarg_types.each do |param_name, param_type|
165
+ next if param_name == :block
166
+
167
+ key = param_name.to_s
168
+ if args.key?(key)
169
+ kwargs[param_name] = coerce_value_to_type(args[key], param_type)
170
+ elsif schema[:required].include?(key)
171
+ return "Error: Missing required parameter: #{key}"
172
+ end
173
+ end
174
+
175
+ # Handle positional args if any
176
+ sig_info.arg_types.each do |param_name, param_type|
177
+ next if param_name == :block
178
+
179
+ key = param_name.to_s
180
+ if args.key?(key)
181
+ kwargs[param_name] = coerce_value_to_type(args[key], param_type)
182
+ elsif schema[:required].include?(key)
183
+ return "Error: Missing required parameter: #{key}"
184
+ end
187
185
  end
188
186
  end
189
187
 
@@ -195,32 +193,6 @@ module DSPy
195
193
 
196
194
  # Subclasses must implement their own call method with their own signature
197
195
 
198
- protected
199
-
200
- # Convert argument to the expected type based on JSON schema
201
- sig { params(value: T.untyped, schema: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
202
- def convert_argument_type(value, schema)
203
- case schema[:type]
204
- when :integer
205
- value.is_a?(Integer) ? value : value.to_i
206
- when :number
207
- # Always convert to Float for :number types to ensure compatibility with strict Float signatures
208
- value.to_f
209
- when :boolean
210
- case value
211
- when true, false
212
- value
213
- when "true", "1", 1
214
- true
215
- when "false", "0", 0
216
- false
217
- else
218
- !!value
219
- end
220
- else
221
- value.to_s
222
- end
223
- end
224
196
  end
225
197
  end
226
198
  end