dspy 0.27.1 → 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.
@@ -24,20 +24,27 @@ module DSPy
24
24
  normalized_messages = format_multimodal_messages(normalized_messages)
25
25
  end
26
26
 
27
- # Set temperature based on model capabilities
28
- temperature = case model
29
- when /^gpt-5/, /^gpt-4o/
30
- 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
31
- else
32
- 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
27
+ # Handle O1 model restrictions - convert system messages to user messages
28
+ if o1_model?(model)
29
+ normalized_messages = handle_o1_messages(normalized_messages)
33
30
  end
34
31
 
35
32
  request_params = {
36
33
  model: model,
37
- messages: normalized_messages,
38
- temperature: temperature
34
+ messages: normalized_messages
39
35
  }
40
36
 
37
+ # Add temperature based on model capabilities
38
+ unless o1_model?(model)
39
+ temperature = case model
40
+ when /^gpt-5/, /^gpt-4o/
41
+ 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
42
+ else
43
+ 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
44
+ end
45
+ request_params[:temperature] = temperature
46
+ end
47
+
41
48
  # Add response format if provided by strategy
42
49
  if response_format
43
50
  request_params[:response_format] = response_format
@@ -148,6 +155,26 @@ module DSPy
148
155
  end
149
156
  end
150
157
  end
158
+
159
+ # Check if model is an O1 reasoning model (includes O1, O3, O4 series)
160
+ def o1_model?(model_name)
161
+ model_name.match?(/^o[134](-.*)?$/)
162
+ end
163
+
164
+ # Handle O1 model message restrictions
165
+ def handle_o1_messages(messages)
166
+ messages.map do |msg|
167
+ # Convert system messages to user messages for O1 models
168
+ if msg[:role] == 'system'
169
+ {
170
+ role: 'user',
171
+ content: "Instructions: #{msg[:content]}"
172
+ }
173
+ else
174
+ msg
175
+ end
176
+ end
177
+ end
151
178
  end
152
179
  end
153
180
  end
@@ -55,7 +55,7 @@ module DSPy
55
55
 
56
56
  # Let strategy handle the error first
57
57
  if strategy.handle_error(e)
58
- DSPy.logger.info("Strategy #{strategy.name} handled error, will try next strategy")
58
+ DSPy.logger.debug("Strategy #{strategy.name} handled error, trying next strategy")
59
59
  next # Try next strategy
60
60
  end
61
61
 
@@ -64,9 +64,18 @@ module DSPy
64
64
  retry_count += 1
65
65
  backoff_time = calculate_backoff(retry_count)
66
66
 
67
- DSPy.logger.warn(
68
- "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
69
- )
67
+ # Use debug for structured output strategies since they often have expected failures
68
+ log_level = ["openai_structured_output", "gemini_structured_output"].include?(strategy.name) ? :debug : :warn
69
+
70
+ if log_level == :debug
71
+ DSPy.logger.debug(
72
+ "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
73
+ )
74
+ else
75
+ DSPy.logger.warn(
76
+ "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
77
+ )
78
+ end
70
79
 
71
80
  Async::Task.current.sleep(backoff_time) if backoff_time > 0
72
81
  retry
@@ -101,8 +110,8 @@ module DSPy
101
110
  sig { params(strategy: Strategies::BaseStrategy).returns(Integer) }
102
111
  def max_retries_for_strategy(strategy)
103
112
  case strategy.name
104
- when "openai_structured_output"
105
- 1 # Structured outputs rarely benefit from retries
113
+ when "openai_structured_output", "gemini_structured_output"
114
+ 1 # Structured outputs rarely benefit from retries, most errors are permanent
106
115
  when "anthropic_extraction"
107
116
  2 # Anthropic can be a bit more variable
108
117
  else
@@ -31,13 +31,13 @@ module DSPy
31
31
 
32
32
  sig { override.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
33
33
  def prepare_request(messages, request_params)
34
- # Convert signature to Gemini schema format
34
+ # Convert signature to Gemini JSON Schema format (supports oneOf/anyOf for unions)
35
35
  schema = DSPy::LM::Adapters::Gemini::SchemaConverter.to_gemini_format(signature_class)
36
36
 
37
- # Add generation_config for structured output
37
+ # Add generation_config for structured output using JSON Schema format
38
38
  request_params[:generation_config] = {
39
39
  response_mime_type: "application/json",
40
- response_schema: schema
40
+ response_json_schema: schema # Use JSON Schema format for proper union support
41
41
  }
42
42
  end
43
43
 
@@ -52,12 +52,25 @@ module DSPy
52
52
  def handle_error(error)
53
53
  # Handle Gemini-specific structured output errors
54
54
  error_msg = error.message.to_s.downcase
55
- if error_msg.include?("schema") || error_msg.include?("generation_config") || error_msg.include?("response_schema")
56
- # Log the error and return true to indicate we handled it
57
- # This allows fallback to another strategy
58
- DSPy.logger.warn("Gemini structured output failed: #{error.message}")
59
- true
55
+
56
+ # Check for permanent errors that shouldn't be retried
57
+ permanent_error_patterns = [
58
+ "schema",
59
+ "generation_config",
60
+ "response_schema",
61
+ "unknown name \"response_mime_type\"",
62
+ "unknown name \"response_schema\"",
63
+ "invalid json payload",
64
+ "no matching sse interaction found", # VCR test configuration issue
65
+ "cannot find field"
66
+ ]
67
+
68
+ if permanent_error_patterns.any? { |pattern| error_msg.include?(pattern) }
69
+ # These are permanent errors - no point retrying
70
+ DSPy.logger.debug("Gemini structured output failed (permanent error, skipping retries): #{error.message}")
71
+ true # Skip retries and try next strategy
60
72
  else
73
+ # Unknown error - let retry logic handle it
61
74
  false
62
75
  end
63
76
  end
data/lib/dspy/lm.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'async'
5
+ require 'securerandom'
5
6
 
6
7
  # Load adapter infrastructure
7
8
  require_relative 'lm/errors'
@@ -42,7 +43,17 @@ module DSPy
42
43
  end
43
44
 
44
45
  def chat(inference_module, input_values, &block)
46
+ # Capture the current DSPy context before entering Sync block
47
+ parent_context = DSPy::Context.current.dup
48
+
45
49
  Sync do
50
+ # Properly restore the context in the new fiber created by Sync
51
+ # We need to set both thread and fiber storage for the new context system
52
+ thread_key = :"dspy_context_#{Thread.current.object_id}"
53
+ Thread.current[thread_key] = parent_context
54
+ Thread.current[:dspy_context] = parent_context # Keep for backward compatibility
55
+ Fiber[:dspy_context] = parent_context
56
+
46
57
  signature_class = inference_module.signature_class
47
58
 
48
59
  # Build messages from inference module
@@ -225,7 +236,7 @@ module DSPy
225
236
  # Wrap LLM call in span tracking
226
237
  response = DSPy::Context.with_span(
227
238
  operation: 'llm.generate',
228
- 'langfuse.observation.type' => 'generation',
239
+ **DSPy::ObservationType::Generation.langfuse_attributes,
229
240
  'langfuse.observation.input' => input_json,
230
241
  'gen_ai.system' => provider,
231
242
  'gen_ai.request.model' => model,
@@ -267,11 +278,26 @@ module DSPy
267
278
  token_usage = extract_token_usage(response)
268
279
 
269
280
  if token_usage.any?
270
- DSPy.log('lm.tokens', **token_usage.merge({
281
+ event_attributes = token_usage.merge({
271
282
  'gen_ai.system' => provider,
272
283
  'gen_ai.request.model' => model,
273
284
  'dspy.signature' => signature_class_name
274
- }))
285
+ })
286
+
287
+ # Add timing and request correlation if available
288
+ request_id = Thread.current[:dspy_request_id]
289
+ start_time = Thread.current[:dspy_request_start_time]
290
+
291
+ if request_id
292
+ event_attributes['request_id'] = request_id
293
+ end
294
+
295
+ if start_time
296
+ duration = Time.now - start_time
297
+ event_attributes['duration'] = duration
298
+ end
299
+
300
+ DSPy.event('lm.tokens', event_attributes)
275
301
  end
276
302
 
277
303
  token_usage
@@ -341,15 +367,32 @@ module DSPy
341
367
  end
342
368
 
343
369
  def execute_raw_chat(messages, &streaming_block)
344
- response = instrument_lm_request(messages, 'RawPrompt') do
345
- # Convert messages to hash format for adapter
346
- hash_messages = messages_to_hash_array(messages)
347
- # Direct adapter call, no strategies or JSON parsing
348
- adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
349
- end
370
+ # Generate unique request ID for tracking
371
+ request_id = SecureRandom.hex(8)
372
+ start_time = Time.now
373
+
374
+ # Store request context for correlation
375
+ Thread.current[:dspy_request_id] = request_id
376
+ Thread.current[:dspy_request_start_time] = start_time
350
377
 
351
- # Return raw response content, not parsed JSON
352
- response.content
378
+ begin
379
+ response = instrument_lm_request(messages, 'RawPrompt') do
380
+ # Convert messages to hash format for adapter
381
+ hash_messages = messages_to_hash_array(messages)
382
+ # Direct adapter call, no strategies or JSON parsing
383
+ adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
384
+ end
385
+
386
+ # Emit the standard lm.tokens event (consistent with other LM calls)
387
+ emit_token_usage(response, 'RawPrompt')
388
+
389
+ # Return raw response content, not parsed JSON
390
+ response.content
391
+ ensure
392
+ # Clean up thread-local storage
393
+ Thread.current[:dspy_request_id] = nil
394
+ Thread.current[:dspy_request_start_time] = nil
395
+ end
353
396
  end
354
397
 
355
398
  # Convert messages to normalized Message objects
@@ -36,17 +36,33 @@ module DSPy
36
36
 
37
37
  sig { override.params(text: String).returns(T::Array[Float]) }
38
38
  def embed(text)
39
- ensure_ready!
40
-
41
- # Preprocess text
42
- cleaned_text = preprocess_text(text)
43
-
44
- # Generate embedding
45
- result = @model.call(cleaned_text)
46
-
47
- # Extract embedding array and normalize
48
- embedding = result.first.to_a
49
- normalize_vector(embedding)
39
+ DSPy::Context.with_span(
40
+ operation: 'embedding.generate',
41
+ **DSPy::ObservationType::Embedding.langfuse_attributes,
42
+ 'embedding.model' => @model_name,
43
+ 'embedding.input' => text[0..200], # Truncate for logging
44
+ 'embedding.input_length' => text.length
45
+ ) do |span|
46
+ ensure_ready!
47
+
48
+ # Preprocess text
49
+ cleaned_text = preprocess_text(text)
50
+
51
+ # Generate embedding
52
+ result = @model.call(cleaned_text)
53
+
54
+ # Extract embedding array and normalize
55
+ embedding = result.first.to_a
56
+ normalized = normalize_vector(embedding)
57
+
58
+ # Add embedding metadata to span
59
+ if span
60
+ span.set_attribute('embedding.dimension', normalized.length)
61
+ span.set_attribute('embedding.magnitude', Math.sqrt(normalized.sum { |x| x * x }))
62
+ end
63
+
64
+ normalized
65
+ end
50
66
  end
51
67
 
52
68
  sig { override.params(texts: T::Array[String]).returns(T::Array[T::Array[Float]]) }
@@ -95,15 +95,32 @@ module DSPy
95
95
  # Semantic search using embeddings
96
96
  sig { params(query: String, user_id: T.nilable(String), limit: T.nilable(Integer), threshold: T.nilable(Float)).returns(T::Array[MemoryRecord]) }
97
97
  def search_memories(query, user_id: nil, limit: 10, threshold: 0.5)
98
- # Generate embedding for the query
99
- query_embedding = @embedding_engine.embed(query)
100
-
101
- # Perform vector search if supported
102
- if @store.supports_vector_search?
103
- @store.vector_search(query_embedding, user_id: user_id, limit: limit, threshold: threshold)
104
- else
105
- # Fallback to text search
106
- @store.search(query, user_id: user_id, limit: limit)
98
+ DSPy::Context.with_span(
99
+ operation: 'memory.search',
100
+ **DSPy::ObservationType::Retriever.langfuse_attributes,
101
+ 'retriever.query' => query,
102
+ 'retriever.user_id' => user_id,
103
+ 'retriever.limit' => limit,
104
+ 'retriever.threshold' => threshold
105
+ ) do |span|
106
+ # Generate embedding for the query
107
+ query_embedding = @embedding_engine.embed(query)
108
+
109
+ # Perform vector search if supported
110
+ results = if @store.supports_vector_search?
111
+ @store.vector_search(query_embedding, user_id: user_id, limit: limit, threshold: threshold)
112
+ else
113
+ # Fallback to text search
114
+ @store.search(query, user_id: user_id, limit: limit)
115
+ end
116
+
117
+ # Add retrieval results to span
118
+ if span
119
+ span.set_attribute('retriever.results_count', results.length)
120
+ span.set_attribute('retriever.results', results.map { |r| { id: r.id, content: r.content[0..100] } }.to_json)
121
+ end
122
+
123
+ results
107
124
  end
108
125
  end
109
126
 
@@ -34,6 +34,8 @@ module DSPy
34
34
  coerce_union_value(value, prop_type)
35
35
  when ->(type) { array_type?(type) }
36
36
  coerce_array_value(value, prop_type)
37
+ when ->(type) { hash_type?(type) }
38
+ coerce_hash_value(value, prop_type)
37
39
  when ->(type) { enum_type?(type) }
38
40
  extract_enum_class(prop_type).deserialize(value)
39
41
  when Float, ->(type) { simple_type_match?(type, Float) }
@@ -88,6 +90,12 @@ module DSPy
88
90
  true
89
91
  end
90
92
 
93
+ # Checks if a type is a hash type
94
+ sig { params(type: T.untyped).returns(T::Boolean) }
95
+ def hash_type?(type)
96
+ type.is_a?(T::Types::TypedHash)
97
+ end
98
+
91
99
  # Checks if a type is a struct type
92
100
  sig { params(type: T.untyped).returns(T::Boolean) }
93
101
  def struct_type?(type)
@@ -124,6 +132,28 @@ module DSPy
124
132
  value.map { |element| coerce_value_to_type(element, element_type) }
125
133
  end
126
134
 
135
+ # Coerces a hash value, converting keys and values as needed
136
+ sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
137
+ def coerce_hash_value(value, prop_type)
138
+ return value unless value.is_a?(Hash)
139
+ return value unless prop_type.is_a?(T::Types::TypedHash)
140
+
141
+ key_type = prop_type.keys
142
+ value_type = prop_type.values
143
+
144
+ # Convert string keys to enum instances if key_type is an enum
145
+ result = if enum_type?(key_type)
146
+ enum_class = extract_enum_class(key_type)
147
+ value.transform_keys { |k| enum_class.deserialize(k.to_s) }
148
+ else
149
+ # For non-enum keys, coerce them to the expected type
150
+ value.transform_keys { |k| coerce_value_to_type(k, key_type) }
151
+ end
152
+
153
+ # Coerce values to their expected types
154
+ result.transform_values { |v| coerce_value_to_type(v, value_type) }
155
+ end
156
+
127
157
  # Coerces a struct value from a hash
128
158
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
129
159
  def coerce_struct_value(value, prop_type)
data/lib/dspy/module.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
+ require_relative 'context'
5
6
 
6
7
  module DSPy
7
8
  class Module
@@ -21,8 +22,25 @@ module DSPy
21
22
  .returns(T.type_parameter(:O))
22
23
  end
23
24
  def forward(**input_values)
24
- # Cast the result of forward_untyped to the expected output type
25
- T.cast(forward_untyped(**input_values), T.type_parameter(:O))
25
+ # Create span for this module's execution
26
+ observation_type = DSPy::ObservationType.for_module_class(self.class)
27
+ DSPy::Context.with_span(
28
+ operation: "#{self.class.name}.forward",
29
+ **observation_type.langfuse_attributes,
30
+ 'langfuse.observation.input' => input_values.to_json,
31
+ 'dspy.module' => self.class.name
32
+ ) do |span|
33
+ result = forward_untyped(**input_values)
34
+
35
+ # Add output to span
36
+ if span && result
37
+ output_json = result.respond_to?(:to_h) ? result.to_h.to_json : result.to_json rescue result.to_s
38
+ span.set_attribute('langfuse.observation.output', output_json)
39
+ end
40
+
41
+ # Cast the result of forward_untyped to the expected output type
42
+ T.cast(result, T.type_parameter(:O))
43
+ end
26
44
  end
27
45
 
28
46
  # The implementation method that subclasses must override
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Langfuse observation types as a T::Enum for type safety
7
+ # Maps to the official Langfuse observation types: https://langfuse.com/docs/observability/features/observation-types
8
+ class ObservationType < T::Enum
9
+ enums do
10
+ # LLM generation calls - used for direct model inference
11
+ Generation = new('generation')
12
+
13
+ # Agent operations - decision-making processes using tools/LLM guidance
14
+ Agent = new('agent')
15
+
16
+ # External tool calls (APIs, functions, etc.)
17
+ Tool = new('tool')
18
+
19
+ # Chains linking different application steps/components
20
+ Chain = new('chain')
21
+
22
+ # Data retrieval operations (vector stores, databases, memory search)
23
+ Retriever = new('retriever')
24
+
25
+ # Embedding generation calls
26
+ Embedding = new('embedding')
27
+
28
+ # Functions that assess quality/relevance of outputs
29
+ Evaluator = new('evaluator')
30
+
31
+ # Generic spans for durations of work units
32
+ Span = new('span')
33
+
34
+ # Discrete events/moments in time
35
+ Event = new('event')
36
+ end
37
+
38
+ # Get the appropriate observation type for a DSPy module class
39
+ sig { params(module_class: T.class_of(DSPy::Module)).returns(ObservationType) }
40
+ def self.for_module_class(module_class)
41
+ case module_class.name
42
+ when /ReAct/, /CodeAct/
43
+ Agent
44
+ when /ChainOfThought/
45
+ Chain
46
+ when /Evaluator/
47
+ Evaluator
48
+ else
49
+ Span
50
+ end
51
+ end
52
+
53
+ # Returns the langfuse attribute key and value as an array
54
+ sig { returns([String, String]) }
55
+ def langfuse_attribute
56
+ ['langfuse.observation.type', serialize]
57
+ end
58
+
59
+ # Returns a hash with the langfuse attribute for easy merging
60
+ sig { returns(T::Hash[String, String]) }
61
+ def langfuse_attributes
62
+ { 'langfuse.observation.type' => serialize }
63
+ end
64
+ end
65
+ end
@@ -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,