dspy 0.2.0 → 0.3.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/re_act.rb CHANGED
@@ -1,253 +1,428 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require_relative 'predict'
6
+ require_relative 'signature'
7
+ require_relative 'chain_of_thought'
8
+ require 'json'
9
+ require_relative 'instrumentation'
10
+
1
11
  module DSPy
2
- # Define the signature for ReAct reasoning
12
+ # Define a simple struct for history entries with proper type annotations
13
+ class HistoryEntry < T::Struct
14
+ const :step, Integer
15
+ prop :thought, T.nilable(String)
16
+ prop :action, T.nilable(String)
17
+ prop :action_input, T.nilable(T.any(String, Numeric, T::Hash[T.untyped, T.untyped], T::Array[T.untyped]))
18
+ prop :observation, T.nilable(String)
19
+
20
+ # Custom serialization to ensure compatibility with the rest of the code
21
+ def to_h
22
+ {
23
+ step: step,
24
+ thought: thought,
25
+ action: action,
26
+ action_input: action_input,
27
+ observation: observation
28
+ }.compact
29
+ end
30
+ end
31
+ # Defines the signature for ReAct reasoning using Sorbet signatures
3
32
  class Thought < DSPy::Signature
4
33
  description "Generate a thought about what to do next to answer the question."
5
34
 
6
35
  input do
7
- required(:question).value(:string).meta(description: 'The question to answer')
8
- required(:history).value(:array).meta(description: 'Previous thoughts and actions, including observations from tools. The agent MUST use information from the history to inform its actions and final answer. Each entry is a hash representing a step in the reasoning process.')
9
- required(:available_tools).value(:string).meta(description: 'List of available tools and their descriptions. The agent MUST choose an action from this list or use "finish".')
36
+ const :question, String,
37
+ description: "The question to answer"
38
+ const :history, T::Array[HistoryEntry],
39
+ description: "Previous thoughts and actions, including observations from tools. The agent MUST use information from the history to inform its actions and final answer. Each entry is a hash representing a step in the reasoning process."
40
+ const :available_tools, T::Array[T::Hash[String, T.untyped]],
41
+ description: "Array of available tools with their JSON schemas. The agent MUST choose an action from the tool names in this list or use \"finish\". For each tool, use the name exactly as specified and provide action_input as a JSON object matching the tool's schema."
10
42
  end
11
43
 
12
44
  output do
13
- required(:thought).value(:string).meta(description: 'Reasoning about what to do next, considering the history and observations.')
14
- required(:action).value(:string).meta(description: 'The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string "finish" to provide the final answer.')
15
- required(:action_input).value(:string).meta(description: 'Input for the chosen action. If action is "finish", this field MUST contain the final answer to the original question. This answer MUST be directly taken from the relevant Observation in the history if available. For example, if an observation showed "Observation: 100.0", and you are finishing, this field MUST be "100.0". Do not leave empty if finishing with an observed answer.')
45
+ const :thought, String,
46
+ description: "Reasoning about what to do next, considering the history and observations."
47
+ const :action, String,
48
+ description: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
49
+ const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
50
+ description: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final answer to the original question. This answer MUST be directly taken from the relevant Observation in the history if available. For example, if an observation showed \"Observation: 100.0\", and you are finishing, this field MUST be \"100.0\". Do not leave empty if finishing with an observed answer."
16
51
  end
17
52
  end
18
53
 
19
- # Define the signature for observing tool results
54
+ class NextStep < T::Enum
55
+ enums do
56
+ Continue = new("continue")
57
+ Finish = new("finish")
58
+ end
59
+ end
60
+
61
+ # Defines the signature for processing observations and deciding next steps
20
62
  class ReActObservation < DSPy::Signature
21
63
  description "Process the observation from a tool and decide what to do next."
22
64
 
23
65
  input do
24
- required(:question).value(:string).meta(description: 'The original question')
25
- required(:history).value(:array).meta(description: 'Previous thoughts, actions, and observations. Each entry is a hash representing a step in the reasoning process.')
26
- required(:observation).value(:string).meta(description: 'The result from the last action')
66
+ const :question, String,
67
+ description: "The original question"
68
+ const :history, T::Array[HistoryEntry],
69
+ description: "Previous thoughts, actions, and observations. Each entry is a hash representing a step in the reasoning process."
70
+ const :observation, String,
71
+ description: "The result from the last action"
27
72
  end
28
73
 
29
74
  output do
30
- required(:interpretation).value(:string).meta(description: 'Interpretation of the observation')
31
- required(:next_step).value(:string).meta(description: 'What to do next: "continue" or "finish"')
75
+ const :interpretation, String,
76
+ description: "Interpretation of the observation"
77
+ const :next_step, NextStep,
78
+ description: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
32
79
  end
33
80
  end
34
81
 
35
- # ReAct Agent Module
36
- class ReAct < DSPy::Module
37
- attr_reader :signature_class, :internal_output_schema, :tools, :max_iterations
82
+ # ReAct Agent using Sorbet signatures
83
+ class ReAct < Predict
84
+ extend T::Sig
38
85
 
39
- # Defines the structure for each entry in the ReAct history
40
- HistoryEntry = Struct.new(:step, :thought, :action, :action_input, :observation, keyword_init: true) do
41
- def to_h
42
- {
43
- step: step,
44
- thought: thought,
45
- action: action,
46
- action_input: action_input,
47
- observation: observation
48
- }
49
- end
50
- end
86
+ FINISH_ACTION = "finish"
87
+ sig { returns(T.class_of(DSPy::Signature)) }
88
+ attr_reader :original_signature_class
89
+
90
+ sig { returns(T.class_of(T::Struct)) }
91
+ attr_reader :enhanced_output_struct
92
+
93
+ sig { returns(T::Hash[String, T.untyped]) }
94
+ attr_reader :tools
51
95
 
96
+ sig { returns(Integer) }
97
+ attr_reader :max_iterations
98
+
99
+
100
+ sig { params(signature_class: T.class_of(DSPy::Signature), tools: T::Array[T.untyped], max_iterations: Integer).void }
52
101
  def initialize(signature_class, tools: [], max_iterations: 5)
53
- super()
54
- @signature_class = signature_class # User's original signature class
55
- @thought_generator = DSPy::ChainOfThought.new(Thought)
56
- @observation_processor = DSPy::Predict.new(ReActObservation)
57
- @tools = tools.map { |tool| [tool.name.downcase, tool] }.to_h # Ensure tool names are stored lowercased for lookup
102
+ @original_signature_class = signature_class
103
+ @tools = T.let({}, T::Hash[String, T.untyped])
104
+ tools.each { |tool| @tools[tool.name.downcase] = tool }
58
105
  @max_iterations = max_iterations
59
106
 
60
- # Define the schema for fields automatically added by ReAct
61
- react_added_output_schema = Dry::Schema.JSON do
62
- optional(:history).array(:hash) do
63
- required(:step).value(:integer)
64
- optional(:thought).value(:string)
65
- optional(:action).value(:string)
66
- optional(:action_input).maybe(:string)
67
- optional(:observation).maybe(:string)
107
+ # Create thought generator using Predict to preserve field descriptions
108
+ @thought_generator = T.let(DSPy::Predict.new(Thought), DSPy::Predict)
109
+
110
+ # Create observation processor using Predict to preserve field descriptions
111
+ @observation_processor = T.let(DSPy::Predict.new(ReActObservation), DSPy::Predict)
112
+
113
+ # Create enhanced output struct with ReAct fields
114
+ @enhanced_output_struct = create_enhanced_output_struct(signature_class)
115
+ enhanced_output_struct = @enhanced_output_struct
116
+
117
+ # Create enhanced signature class
118
+ enhanced_signature = Class.new(DSPy::Signature) do
119
+ # Set the description
120
+ description signature_class.description
121
+
122
+ # Use the same input struct
123
+ @input_struct_class = signature_class.input_struct_class
124
+
125
+ # Use the enhanced output struct with ReAct fields
126
+ @output_struct_class = enhanced_output_struct
127
+
128
+ class << self
129
+ attr_reader :input_struct_class, :output_struct_class
68
130
  end
69
- optional(:iterations).value(:integer).meta(description: 'Number of iterations taken by the ReAct agent.')
70
131
  end
71
132
 
72
- # Create the augmented internal output schema by combining user's output schema and ReAct's added fields
73
- @internal_output_schema = Dry::Schema.JSON(parent: [signature_class.output_schema, react_added_output_schema])
133
+ # Call parent constructor with enhanced signature
134
+ super(enhanced_signature)
74
135
  end
75
136
 
76
- def forward(**input_values)
77
- # Validate input against the signature's input schema
78
- input_validation_result = @signature_class.input_schema.call(input_values)
79
- unless input_validation_result.success?
80
- raise DSPy::PredictionInvalidError.new(input_validation_result.errors)
81
- end
137
+ sig { params(kwargs: T.untyped).returns(T.untyped).override }
138
+ def forward(**kwargs)
139
+ lm = config.lm || DSPy.config.lm
140
+ # Prepare instrumentation payload
141
+ input_fields = kwargs.keys.map(&:to_s)
142
+ available_tools = @tools.keys
143
+
144
+ # Instrument the entire ReAct agent lifecycle
145
+ result = Instrumentation.instrument('dspy.react', {
146
+ signature_class: @original_signature_class.name,
147
+ model: lm.model,
148
+ provider: lm.provider,
149
+ input_fields: input_fields,
150
+ max_iterations: @max_iterations,
151
+ available_tools: available_tools
152
+ }) do
153
+ # Validate input using Sorbet struct validation
154
+ input_struct = @original_signature_class.input_struct_class.new(**kwargs)
155
+
156
+ # Get the question (assume first field is the question for now)
157
+ question = T.cast(input_struct.serialize.values.first, String)
158
+
159
+ history = T.let([], T::Array[HistoryEntry])
160
+ available_tools_desc = @tools.map { |name, tool| JSON.parse(tool.schema) }
161
+
162
+ final_answer = T.let(nil, T.nilable(String))
163
+ iterations_count = 0
164
+ last_observation = T.let(nil, T.nilable(String))
165
+ tools_used = []
166
+
167
+ while @max_iterations.nil? || iterations_count < @max_iterations
168
+ iterations_count += 1
169
+
170
+ # Instrument each iteration
171
+ iteration_result = Instrumentation.instrument('dspy.react.iteration', {
172
+ iteration: iterations_count,
173
+ max_iterations: @max_iterations,
174
+ history_length: history.length,
175
+ tools_used_so_far: tools_used.uniq
176
+ }) do
177
+ # Get next thought from LM
178
+ thought_obj = @thought_generator.forward(
179
+ question: question,
180
+ history: history,
181
+ available_tools: available_tools_desc
182
+ )
183
+ step = iterations_count
184
+ thought = thought_obj.thought
185
+ action = thought_obj.action
186
+ action_input = thought_obj.action_input
187
+
188
+ # Break if finish action
189
+ if action&.downcase == 'finish'
190
+ final_answer = handle_finish_action(action_input, last_observation, step, thought, action, history)
191
+ break
192
+ end
82
193
 
83
- # Assume the first input field is the primary question for the ReAct loop
84
- # This is a convention; a more robust solution might involve explicit mapping
85
- # or requiring a specific field name like 'question'.
86
- question_field_name = @signature_class.input_schema.key_map.first.name.to_sym
87
- question = input_values[question_field_name]
194
+ # Track tools used
195
+ tools_used << action.downcase if action && @tools[action.downcase]
196
+
197
+ # Execute action
198
+ observation = if action && @tools[action.downcase]
199
+ # Instrument tool call
200
+ Instrumentation.instrument('dspy.react.tool_call', {
201
+ iteration: iterations_count,
202
+ tool_name: action.downcase,
203
+ tool_input: action_input
204
+ }) do
205
+ execute_action(action, action_input)
206
+ end
207
+ else
208
+ "Unknown action: #{action}. Available actions: #{@tools.keys.join(', ')}, finish"
209
+ end
210
+
211
+ last_observation = observation
212
+
213
+ # Add to history
214
+ history << HistoryEntry.new(
215
+ step: step,
216
+ thought: thought,
217
+ action: action,
218
+ action_input: action_input,
219
+ observation: observation
220
+ )
221
+
222
+ # Process observation to decide next step
223
+ if observation && !observation.include?("Unknown action")
224
+ observation_result = @observation_processor.forward(
225
+ question: question,
226
+ history: history,
227
+ observation: observation
228
+ )
88
229
 
89
- history = [] # Initialize history as an array of HistoryEntry objects
90
- available_tools_desc = @tools.map { |name, tool| "- #{name}: #{tool.description}" }.join("\n")
230
+ # If observation processor suggests finishing, generate final thought
231
+ if observation_result.next_step == NextStep::Finish
232
+ final_thought = @thought_generator.forward(
233
+ question: question,
234
+ history: history,
235
+ available_tools: available_tools_desc
236
+ )
91
237
 
92
- final_answer = nil
93
- iterations_count = 0
238
+ # Force finish action if observation processor suggests it
239
+ if final_thought.action&.downcase != 'finish'
240
+ forced_answer = if observation_result.interpretation && !observation_result.interpretation.empty?
241
+ observation_result.interpretation
242
+ else
243
+ observation
244
+ end
245
+ final_answer = handle_finish_action(forced_answer, last_observation, step + 1, final_thought.thought, 'finish', history)
246
+ else
247
+ final_answer = handle_finish_action(final_thought.action_input, last_observation, step + 1, final_thought.thought, final_thought.action, history)
248
+ end
249
+ break
250
+ end
251
+ end
94
252
 
95
- @max_iterations.times do |i|
96
- iterations_count = i + 1
97
- current_step_history = { step: iterations_count }
253
+ # Emit iteration complete event
254
+ Instrumentation.emit('dspy.react.iteration_complete', {
255
+ iteration: iterations_count,
256
+ thought: thought,
257
+ action: action,
258
+ action_input: action_input,
259
+ observation: observation,
260
+ tools_used: tools_used.uniq
261
+ })
262
+ end
98
263
 
99
- # Generate thought and action
100
- thought_result = @thought_generator.call(
101
- question: question,
102
- history: history.map(&:to_h),
103
- available_tools: available_tools_desc
104
- )
105
-
106
- thought = thought_result.thought
107
- action = thought_result.action
108
- current_action_input = thought_result.action_input # What LM provided
109
-
110
- current_step_history[:thought] = thought
111
- current_step_history[:action] = action
112
-
113
- if action.downcase == "finish"
114
- # If LM says 'finish' but gives empty input, try to use last observation
115
- if current_action_input.nil? || current_action_input.strip.empty?
116
- # Try to find the last observation in history
117
- last_entry_with_observation = history.reverse.find { |entry| entry.observation && !entry.observation.strip.empty? }
118
-
119
- if last_entry_with_observation
120
- last_observation_value = last_entry_with_observation.observation.strip
121
- DSPy.logger.info(
122
- module: "ReAct",
123
- status: "Finish action had empty input. Overriding with last observation.",
124
- original_input: current_action_input,
125
- derived_input: last_observation_value
126
- )
127
- current_action_input = last_observation_value # Override
128
- else
129
- DSPy.logger.warn(module: "ReAct", status: "Finish action had empty input, no prior Observation found in history.", original_input: current_action_input)
130
- end
264
+ # Check if max iterations reached
265
+ if iterations_count >= @max_iterations && final_answer.nil?
266
+ Instrumentation.emit('dspy.react.max_iterations', {
267
+ iteration_count: iterations_count,
268
+ max_iterations: @max_iterations,
269
+ tools_used: tools_used.uniq,
270
+ final_history_length: history.length
271
+ })
131
272
  end
132
- final_answer = current_action_input # Set final answer from (potentially overridden) input
133
273
  end
134
274
 
135
- # Add thought to history using current_action_input, which might have been overridden for 'finish'
136
- current_step_history[:action_input] = current_action_input
275
+ # Create enhanced output with all ReAct data
276
+ output_field_name = @original_signature_class.output_struct_class.props.keys.first
277
+ output_data = kwargs.merge({
278
+ history: history.map(&:to_h),
279
+ iterations: iterations_count,
280
+ tools_used: tools_used.uniq
281
+ })
282
+ output_data[output_field_name] = final_answer || "No answer reached within #{@max_iterations} iterations"
283
+ enhanced_output = @enhanced_output_struct.new(**output_data)
137
284
 
138
- # Check if we should finish (using the original action from LM)
139
- if action.downcase == "finish"
140
- DSPy.logger.info(module: "ReAct", status: "Finishing loop after thought", action: action, final_answer: final_answer, question: question)
141
- history << HistoryEntry.new(**current_step_history) # Add final thought/action before breaking
142
- break
143
- end
285
+ enhanced_output
286
+ end
287
+
288
+ result
289
+ end
144
290
 
145
- # Execute the action
146
- observation_text = execute_action(action, current_action_input) # current_action_input is original for non-finish
147
- current_step_history[:observation] = observation_text
148
- history << HistoryEntry.new(**current_step_history) # Add completed step to history
291
+ private
149
292
 
150
- # Process the observation
151
- obs_result = @observation_processor.call(
152
- question: question,
153
- history: history.map(&:to_h),
154
- observation: observation_text
155
- )
156
-
157
- if obs_result.next_step.downcase == "finish"
158
- DSPy.logger.info(module: "ReAct", status: "Observation processor suggests finish. Generating final thought.", question: question, history_before_final_thought: history.map(&:to_h))
159
- # Generate final thought/answer if observation processor decides to finish
160
-
161
- # Create a new history entry for this final thought sequence
162
- final_thought_step_history = { step: iterations_count + 1 } # This is like a sub-step or a new thought step
163
-
164
- final_thought_result = @thought_generator.call(
165
- question: question,
166
- history: history.map(&:to_h), # history now includes the last observation
167
- available_tools: available_tools_desc
168
- )
169
- DSPy.logger.info(module: "ReAct", status: "Finishing after observation and final thought", final_action: final_thought_result.action, final_action_input: final_thought_result.action_input, question: question)
170
-
171
- final_thought_action = final_thought_result.action
172
- final_thought_action_input_val = final_thought_result.action_input # LM provided
173
-
174
- final_thought_step_history[:thought] = final_thought_result.thought
175
- final_thought_step_history[:action] = final_thought_action
176
-
177
- if final_thought_action.downcase == "finish"
178
- if final_thought_action_input_val.nil? || final_thought_action_input_val.strip.empty?
179
- # Find the last observation in the history array
180
- last_entry_with_observation = history.reverse.find { |entry| entry.observation && !entry.observation.strip.empty? }
181
-
182
- if last_entry_with_observation
183
- last_observation_value_ft = last_entry_with_observation.observation.strip
184
- DSPy.logger.info(
185
- module: "ReAct",
186
- status: "Final thought 'finish' action had empty input. Overriding with last observation.",
187
- original_input: final_thought_action_input_val,
188
- derived_input: last_observation_value_ft
189
- )
190
- final_thought_action_input_val = last_observation_value_ft # Override
191
- else
192
- DSPy.logger.warn(module: "ReAct", status: "Final thought 'finish' action had empty input, last observation also empty/not found cleanly.", original_input: final_thought_action_input_val)
193
- end
194
- else
195
- # This case is if LM provides 'finish' but no observation to fall back on in history array (should be rare if history is populated correctly)
196
- DSPy.logger.warn(module: "ReAct", status: "Final thought 'finish' action had empty input, no prior Observation found in history array.", original_input: final_thought_action_input_val) if (history.empty? || !history.any? { |entry| entry.observation && !entry.observation.strip.empty? })
197
- end
293
+ sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(T::Struct)) }
294
+ def create_enhanced_output_struct(signature_class)
295
+ # Get original input and output props
296
+ input_props = signature_class.input_struct_class.props
297
+ output_props = signature_class.output_struct_class.props
298
+
299
+ # Create new struct class with input, output, and ReAct fields
300
+ Class.new(T::Struct) do
301
+ # Add all input fields
302
+ input_props.each do |name, prop|
303
+ # Extract the type and other options
304
+ type = prop[:type]
305
+ options = prop.except(:type, :type_object, :accessor_key, :sensitivity, :redaction)
306
+
307
+ # Handle default values
308
+ if options[:default]
309
+ const name, type, default: options[:default]
310
+ elsif options[:factory]
311
+ const name, type, factory: options[:factory]
312
+ else
313
+ const name, type
198
314
  end
315
+ end
199
316
 
200
- final_thought_step_history[:action_input] = final_thought_action_input_val
201
- history << HistoryEntry.new(**final_thought_step_history) # Add this final step to history
202
-
203
- final_answer = final_thought_action_input_val # Use (potentially overridden) value
204
- iterations_count += 1 # Account for this extra thought step in iterations
205
- break
317
+ # Add all output fields
318
+ output_props.each do |name, prop|
319
+ # Extract the type and other options
320
+ type = prop[:type]
321
+ options = prop.except(:type, :type_object, :accessor_key, :sensitivity, :redaction)
322
+
323
+ # Handle default values
324
+ if options[:default]
325
+ const name, type, default: options[:default]
326
+ elsif options[:factory]
327
+ const name, type, factory: options[:factory]
328
+ else
329
+ const name, type
330
+ end
206
331
  end
332
+
333
+ # Add ReAct-specific fields
334
+ prop :history, T::Array[T::Hash[Symbol, T.untyped]]
335
+ prop :iterations, Integer
336
+ prop :tools_used, T::Array[String]
207
337
  end
338
+ end
208
339
 
209
- final_answer ||= "Unable to find answer within #{@max_iterations} iterations"
210
- DSPy.logger.info(module: "ReAct", status: "Final answer determined", final_answer: final_answer, question: question) if final_answer.nil? || final_answer.empty? || final_answer == "Unable to find answer within #{@max_iterations} iterations"
340
+ sig { params(action: String, action_input: T.untyped).returns(String) }
341
+ def execute_action(action, action_input)
342
+ tool_name = action.downcase
343
+ tool = @tools[tool_name]
344
+ return "Tool '#{action}' not found. Available tools: #{@tools.keys.join(', ')}" unless tool
211
345
 
212
- # Prepare output data
213
- output_data = {}
346
+ begin
347
+ result = if action_input.nil? ||
348
+ (action_input.is_a?(String) && action_input.strip.empty?)
349
+ # No input provided
350
+ tool.dynamic_call({})
351
+ else
352
+ # Pass the action_input directly to dynamic_call, which can handle
353
+ # either a Hash or a JSON string
354
+ tool.dynamic_call(action_input)
355
+ end
356
+ result.to_s
357
+ rescue => e
358
+ "Error executing tool '#{action}': #{e.message}"
359
+ end
360
+ end
361
+
362
+ sig { params(output: T.untyped).void }
363
+ def validate_output_schema!(output)
364
+ # Validate that output is an instance of the enhanced output struct
365
+ unless output.is_a?(@enhanced_output_struct)
366
+ raise "Output must be an instance of #{@enhanced_output_struct}, got #{output.class}"
367
+ end
214
368
 
215
- # Populate the primary answer field from the user's original signature
216
- # This assumes the first defined output field in the user's signature is the main answer field.
217
- user_primary_output_field = @signature_class.output_schema.key_map.first.name.to_sym
218
- output_data[user_primary_output_field] = final_answer
369
+ # Validate original signature output fields are present
370
+ @original_signature_class.output_struct_class.props.each do |field_name, _prop|
371
+ unless output.respond_to?(field_name)
372
+ raise "Missing required field: #{field_name}"
373
+ end
374
+ end
219
375
 
220
- # Add ReAct-specific fields
221
- output_data[:history] = history.map(&:to_h) # Convert HistoryEntry objects to hashes for schema validation
222
- output_data[:iterations] = iterations_count
376
+ # Validate ReAct-specific fields
377
+ unless output.respond_to?(:history) && output.history.is_a?(Array)
378
+ raise "Missing or invalid history field"
379
+ end
223
380
 
224
- # Validate and create PORO using the augmented internal_output_schema
225
- output_validation_result = @internal_output_schema.call(output_data)
226
- unless output_validation_result.success?
227
- DSPy.logger.error(module: "ReAct", status: "Internal output validation failed", errors: output_validation_result.errors.to_h, data: output_data)
228
- raise DSPy::PredictionInvalidError.new(output_validation_result.errors)
381
+ unless output.respond_to?(:iterations) && output.iterations.is_a?(Integer)
382
+ raise "Missing or invalid iterations field"
229
383
  end
230
384
 
231
- # Create PORO with all fields (user's + ReAct's)
232
- # Sorting keys for Data.define ensures a consistent order for the PORO attributes.
233
- poro_class = Data.define(*output_validation_result.to_h.keys.sort)
234
- poro_class.new(**output_validation_result.to_h)
385
+ unless output.respond_to?(:tools_used) && output.tools_used.is_a?(Array)
386
+ raise "Missing or invalid tools_used field"
387
+ end
235
388
  end
236
389
 
237
- private
390
+ sig { override.returns(T::Hash[Symbol, T.untyped]) }
391
+ def generate_example_output
392
+ example = super
393
+ example[:history] = [
394
+ {
395
+ step: 1,
396
+ thought: "I need to think about this question...",
397
+ action: "some_tool",
398
+ action_input: "input for tool",
399
+ observation: "result from tool"
400
+ }
401
+ ]
402
+ example[:iterations] = 1
403
+ example[:tools_used] = ["some_tool"]
404
+ example
405
+ end
238
406
 
239
- def execute_action(action, action_input)
240
- tool = @tools[action.downcase] # Lookup with downcased action name
407
+ sig { params(action_input: T.untyped, last_observation: T.nilable(String), step: Integer, thought: String, action: String, history: T::Array[HistoryEntry]).returns(String) }
408
+ def handle_finish_action(action_input, last_observation, step, thought, action, history)
409
+ final_answer = action_input.to_s
241
410
 
242
- if tool.nil?
243
- return "Error: Unknown tool '#{action}'. Available tools: #{@tools.keys.join(', ')}"
411
+ # If final_answer is empty but we have a last observation, use it
412
+ if (final_answer.nil? || final_answer.empty?) && last_observation
413
+ final_answer = last_observation
244
414
  end
245
415
 
246
- begin
247
- tool.call(action_input)
248
- rescue => e
249
- "Error executing #{action}: #{e.message}"
250
- end
416
+ # Always add the finish action to history
417
+ history << HistoryEntry.new(
418
+ step: step,
419
+ thought: thought,
420
+ action: action,
421
+ action_input: final_answer,
422
+ observation: nil # No observation for finish action
423
+ )
424
+
425
+ final_answer
251
426
  end
252
427
  end
253
428
  end