dspy 0.1.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.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+ require 'dry-configurable'
5
+
6
+ module DSPy
7
+ class Module
8
+ extend T::Sig
9
+ extend T::Generic
10
+ include Dry::Configurable
11
+
12
+ # Per-instance LM configuration
13
+ setting :lm, default: nil
14
+
15
+ # The main forward method that users will call is generic and type parameterized
16
+ sig do
17
+ type_parameters(:I, :O)
18
+ .params(
19
+ input_values: T.type_parameter(:I)
20
+ )
21
+ .returns(T.type_parameter(:O))
22
+ end
23
+ 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))
26
+ end
27
+
28
+ # The implementation method that subclasses must override
29
+ sig { params(input_values: T.untyped).returns(T.untyped) }
30
+ def forward_untyped(**input_values)
31
+ raise NotImplementedError, "Subclasses must implement forward_untyped method"
32
+ end
33
+
34
+ # The main call method that users will call is generic and type parameterized
35
+ sig do
36
+ type_parameters(:I, :O)
37
+ .params(
38
+ input_values: T.type_parameter(:I)
39
+ )
40
+ .returns(T.type_parameter(:O))
41
+ end
42
+ def call(**input_values)
43
+ forward(**input_values)
44
+ end
45
+
46
+ # The implementation method for call
47
+ sig { params(input_values: T.untyped).returns(T.untyped) }
48
+ def call_untyped(**input_values)
49
+ forward_untyped(**input_values)
50
+ end
51
+
52
+ # Get the configured LM for this instance, falling back to global
53
+ sig { returns(T.untyped) }
54
+ def lm
55
+ config.lm || DSPy.config.lm
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+ require_relative 'module'
5
+ require_relative 'instrumentation'
6
+
7
+ module DSPy
8
+ # Exception raised when prediction fails validation
9
+ class PredictionInvalidError < StandardError
10
+ extend T::Sig
11
+
12
+ sig { params(errors: T::Hash[T.untyped, T.untyped]).void }
13
+ def initialize(errors)
14
+ @errors = errors
15
+ super("Prediction validation failed: #{errors}")
16
+ end
17
+
18
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
19
+ attr_reader :errors
20
+ end
21
+
22
+ class Predict < DSPy::Module
23
+ extend T::Sig
24
+
25
+ sig { returns(T.class_of(Signature)) }
26
+ attr_reader :signature_class
27
+
28
+ sig { params(signature_class: T.class_of(Signature)).void }
29
+ def initialize(signature_class)
30
+ super()
31
+ @signature_class = signature_class
32
+ end
33
+
34
+ sig { returns(String) }
35
+ def system_signature
36
+ <<-PROMPT
37
+ Your input schema fields are:
38
+ ```json
39
+ #{JSON.generate(@signature_class.input_json_schema)}
40
+ ```
41
+ Your output schema fields are:
42
+ ```json
43
+ #{JSON.generate(@signature_class.output_json_schema)}
44
+ ````
45
+
46
+ All interactions will be structured in the following way, with the appropriate values filled in.
47
+
48
+ ## Input values
49
+ ```json
50
+ {input_values}
51
+ ```
52
+ ## Output values
53
+ Respond exclusively with the output schema fields in the json block below.
54
+ ```json
55
+ {output_values}
56
+ ```
57
+
58
+ In adhering to this structure, your objective is: #{@signature_class.description}
59
+
60
+ PROMPT
61
+ end
62
+
63
+ sig { params(input_values: T::Hash[Symbol, T.untyped]).returns(String) }
64
+ def user_signature(input_values)
65
+ <<-PROMPT
66
+ ## Input Values
67
+ ```json
68
+ #{JSON.generate(input_values)}
69
+ ```
70
+
71
+ Respond with the corresponding output schema fields wrapped in a ```json ``` block,
72
+ starting with the heading `## Output values`.
73
+ PROMPT
74
+ end
75
+
76
+ sig { override.params(kwargs: T.untyped).returns(T.type_parameter(:O)) }
77
+ def forward(**kwargs)
78
+ @last_input_values = kwargs.clone
79
+ T.cast(forward_untyped(**kwargs), T.type_parameter(:O))
80
+ end
81
+
82
+ sig { params(input_values: T.untyped).returns(T.untyped) }
83
+ def forward_untyped(**input_values)
84
+ # Prepare instrumentation payload
85
+ input_fields = input_values.keys.map(&:to_s)
86
+
87
+ Instrumentation.instrument('dspy.predict', {
88
+ signature_class: @signature_class.name,
89
+ model: lm.model,
90
+ provider: lm.provider,
91
+ input_fields: input_fields
92
+ }) do
93
+ # Validate input
94
+ begin
95
+ _input_struct = @signature_class.input_struct_class.new(**input_values)
96
+ rescue ArgumentError => e
97
+ # Emit validation error event
98
+ Instrumentation.emit('dspy.predict.validation_error', {
99
+ signature_class: @signature_class.name,
100
+ validation_type: 'input',
101
+ validation_errors: { input: e.message }
102
+ })
103
+ raise PredictionInvalidError.new({ input: e.message })
104
+ end
105
+
106
+ # Call LM
107
+ output_attributes = lm.chat(self, input_values)
108
+
109
+ output_attributes = output_attributes.transform_keys(&:to_sym)
110
+
111
+ output_props = @signature_class.output_struct_class.props
112
+ output_attributes = output_attributes.map do |key, value|
113
+ prop_type = output_props[key][:type] if output_props[key]
114
+ if prop_type
115
+ # Check if it's an enum (can be raw Class or T::Types::Simple)
116
+ enum_class = if prop_type.is_a?(Class) && prop_type < T::Enum
117
+ prop_type
118
+ elsif prop_type.is_a?(T::Types::Simple) && prop_type.raw_type < T::Enum
119
+ prop_type.raw_type
120
+ end
121
+
122
+ if enum_class
123
+ [key, enum_class.deserialize(value)]
124
+ elsif prop_type == Float || (prop_type.is_a?(T::Types::Simple) && prop_type.raw_type == Float)
125
+ [key, value.to_f]
126
+ elsif prop_type == Integer || (prop_type.is_a?(T::Types::Simple) && prop_type.raw_type == Integer)
127
+ [key, value.to_i]
128
+ else
129
+ [key, value]
130
+ end
131
+ else
132
+ [key, value]
133
+ end
134
+ end.to_h
135
+
136
+ # Create combined struct with both input and output values
137
+ begin
138
+ combined_struct = create_combined_struct_class
139
+ all_attributes = input_values.merge(output_attributes)
140
+ combined_struct.new(**all_attributes)
141
+ rescue ArgumentError => e
142
+ raise PredictionInvalidError.new({ output: e.message })
143
+ rescue TypeError => e
144
+ raise PredictionInvalidError.new({ output: e.message })
145
+ end
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ sig { returns(T.class_of(T::Struct)) }
152
+ def create_combined_struct_class
153
+ input_props = @signature_class.input_struct_class.props
154
+ output_props = @signature_class.output_struct_class.props
155
+
156
+ # Create a new struct class that combines input and output fields
157
+ Class.new(T::Struct) do
158
+ extend T::Sig
159
+
160
+ # Add input fields
161
+ input_props.each do |name, prop_info|
162
+ if prop_info[:rules]&.any? { |rule| rule.is_a?(T::Props::NilableRules) }
163
+ prop name, prop_info[:type], default: prop_info[:default]
164
+ else
165
+ const name, prop_info[:type], default: prop_info[:default]
166
+ end
167
+ end
168
+
169
+ # Add output fields
170
+ output_props.each do |name, prop_info|
171
+ if prop_info[:rules]&.any? { |rule| rule.is_a?(T::Props::NilableRules) }
172
+ prop name, prop_info[:type], default: prop_info[:default]
173
+ else
174
+ const name, prop_info[:type], default: prop_info[:default]
175
+ end
176
+ end
177
+
178
+ # Add to_h method to serialize the struct to a hash
179
+ define_method :to_h do
180
+ hash = {}
181
+
182
+ # Add all properties
183
+ self.class.props.keys.each do |key|
184
+ hash[key] = self.send(key)
185
+ end
186
+
187
+ hash
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +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
+
11
+ module DSPy
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
32
+ class Thought < DSPy::Signature
33
+ description "Generate a thought about what to do next to answer the question."
34
+
35
+ input do
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."
42
+ end
43
+
44
+ output do
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."
51
+ end
52
+ end
53
+
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
62
+ class ReActObservation < DSPy::Signature
63
+ description "Process the observation from a tool and decide what to do next."
64
+
65
+ input do
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"
72
+ end
73
+
74
+ output do
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}'"
79
+ end
80
+ end
81
+
82
+ # ReAct Agent using Sorbet signatures
83
+ class ReAct < Predict
84
+ extend T::Sig
85
+
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
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 }
101
+ def initialize(signature_class, tools: [], max_iterations: 5)
102
+ @original_signature_class = signature_class
103
+ @tools = T.let({}, T::Hash[String, T.untyped])
104
+ tools.each { |tool| @tools[tool.name.downcase] = tool }
105
+ @max_iterations = max_iterations
106
+
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
130
+ end
131
+ end
132
+
133
+ # Call parent constructor with enhanced signature
134
+ super(enhanced_signature)
135
+ end
136
+
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
193
+
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
+ )
229
+
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
+ )
237
+
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
252
+
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
263
+
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
+ })
272
+ end
273
+ end
274
+
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)
284
+
285
+ enhanced_output
286
+ end
287
+
288
+ result
289
+ end
290
+
291
+ private
292
+
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
314
+ end
315
+ end
316
+
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
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]
337
+ end
338
+ end
339
+
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
345
+
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
368
+
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
375
+
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
380
+
381
+ unless output.respond_to?(:iterations) && output.iterations.is_a?(Integer)
382
+ raise "Missing or invalid iterations field"
383
+ end
384
+
385
+ unless output.respond_to?(:tools_used) && output.tools_used.is_a?(Array)
386
+ raise "Missing or invalid tools_used field"
387
+ end
388
+ end
389
+
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
406
+
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
410
+
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
414
+ end
415
+
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
426
+ end
427
+ end
428
+ end