dspy 0.27.5 → 0.28.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
@@ -15,7 +15,7 @@ module DSPy
15
15
  prop :thought, T.nilable(String)
16
16
  prop :action, T.nilable(String)
17
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)
18
+ prop :observation, T.untyped
19
19
 
20
20
  # Custom serialization to ensure compatibility with the rest of the code
21
21
  def to_h
@@ -37,7 +37,7 @@ module DSPy
37
37
  description: "Reasoning about what to do next, considering the history and observations."
38
38
  const :action, String,
39
39
  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."
40
- const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
40
+ const :action_input, T.untyped,
41
41
  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 result based on processing the input data. This result MUST be directly taken from the relevant Observation in the history if available."
42
42
  end
43
43
  end
@@ -66,6 +66,11 @@ module DSPy
66
66
  extend T::Sig
67
67
  include Mixins::StructBuilder
68
68
 
69
+ # Custom error classes
70
+ class MaxIterationsError < StandardError; end
71
+ class InvalidActionError < StandardError; end
72
+ class TypeMismatchError < StandardError; end
73
+
69
74
  # AvailableTool struct for better type safety in ReAct agents
70
75
  class AvailableTool < T::Struct
71
76
  const :name, String
@@ -153,6 +158,30 @@ module DSPy
153
158
 
154
159
  private
155
160
 
161
+ # Serialize value for LLM display
162
+ sig { params(value: T.untyped).returns(T.untyped) }
163
+ def serialize_for_llm(value)
164
+ return value if value.nil?
165
+ return value if value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
166
+
167
+ # For structured data, serialize to JSON-compatible format
168
+ TypeSerializer.serialize(value)
169
+ end
170
+
171
+ # Serialize history for LLM consumption
172
+ sig { params(history: T::Array[HistoryEntry]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
173
+ def serialize_history_for_llm(history)
174
+ history.map do |entry|
175
+ {
176
+ step: entry.step,
177
+ thought: entry.thought,
178
+ action: entry.action,
179
+ action_input: serialize_for_llm(entry.action_input),
180
+ observation: serialize_for_llm(entry.observation)
181
+ }.compact
182
+ end
183
+ end
184
+
156
185
  # Creates a dynamic ActionEnum class with tool names and "finish"
157
186
  sig { returns(T.class_of(T::Enum)) }
158
187
  def create_action_enum_class
@@ -202,7 +231,7 @@ module DSPy
202
231
  description: "Reasoning about what to do next, considering the history and observations."
203
232
  const :action, action_enum_class,
204
233
  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."
205
- const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
234
+ const :action_input, T.untyped,
206
235
  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 result based on processing the input data."
207
236
  end
208
237
  end
@@ -222,7 +251,7 @@ module DSPy
222
251
  description: "Serialized representation of all input fields"
223
252
  const :history, T::Array[HistoryEntry],
224
253
  description: "Previous thoughts, actions, and observations."
225
- const :observation, String,
254
+ const :observation, T.untyped,
226
255
  description: "The result from the last action"
227
256
  end
228
257
 
@@ -240,7 +269,7 @@ module DSPy
240
269
  sig { params(input_struct: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
241
270
  def execute_react_reasoning_loop(input_struct)
242
271
  history = T.let([], T::Array[HistoryEntry])
243
- available_tools_desc = @tools.map { |name, tool|
272
+ available_tools_desc = @tools.map { |name, tool|
244
273
  schema = JSON.parse(tool.schema)
245
274
  AvailableTool.new(
246
275
  name: name,
@@ -248,9 +277,9 @@ module DSPy
248
277
  schema: schema.transform_keys(&:to_sym)
249
278
  )
250
279
  }
251
- final_answer = T.let(nil, T.nilable(String))
280
+ final_answer = T.let(nil, T.untyped)
252
281
  iterations_count = 0
253
- last_observation = T.let(nil, T.nilable(String))
282
+ last_observation = T.let(nil, T.untyped)
254
283
  tools_used = []
255
284
 
256
285
  while should_continue_iteration?(iterations_count, final_answer)
@@ -276,12 +305,12 @@ module DSPy
276
305
  history: history,
277
306
  iterations: iterations_count,
278
307
  tools_used: tools_used.uniq,
279
- final_answer: final_answer || default_no_answer_message
308
+ final_answer: final_answer
280
309
  }
281
310
  end
282
311
 
283
312
  # Executes a single iteration of the ReAct loop
284
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
313
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], iteration: Integer, tools_used: T::Array[String], last_observation: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
285
314
  def execute_single_iteration(input_struct, history, available_tools_desc, iteration, tools_used, last_observation)
286
315
  # Track each iteration with agent span
287
316
  DSPy::Context.with_span(
@@ -296,7 +325,7 @@ module DSPy
296
325
  # Generate thought and action
297
326
  thought_obj = @thought_generator.forward(
298
327
  input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
299
- history: history,
328
+ history: serialize_history_for_llm(history),
300
329
  available_tools: available_tools_desc
301
330
  )
302
331
 
@@ -371,26 +400,134 @@ module DSPy
371
400
  output_field_name = @original_signature_class.output_struct_class.props.keys.first
372
401
  final_answer = reasoning_result[:final_answer]
373
402
 
403
+ # If final_answer is nil, max iterations was reached without completion
404
+ if final_answer.nil?
405
+ iterations = reasoning_result[:iterations]
406
+ tools_used = reasoning_result[:tools_used]
407
+ raise MaxIterationsError, "Agent reached maximum iterations (#{iterations}) without producing a final answer. Tools used: #{tools_used.join(', ')}"
408
+ end
409
+
374
410
  output_data = input_kwargs.merge({
375
411
  history: reasoning_result[:history].map(&:to_h),
376
412
  iterations: reasoning_result[:iterations],
377
413
  tools_used: reasoning_result[:tools_used]
378
414
  })
379
415
 
380
- # Check if final_answer is a String but the expected type is NOT String
381
- # This happens when max iterations is reached or the LLM generates an error message
416
+ # Get the expected output type
382
417
  output_field_type = @original_signature_class.output_struct_class.props[output_field_name][:type_object]
383
- if final_answer.is_a?(String) && !string_compatible_type?(output_field_type)
384
- output_data[output_field_name] = default_value_for_type(output_field_type)
418
+
419
+ # Try to deserialize final_answer to match the expected output type
420
+ deserialized_value = deserialize_final_answer(final_answer, output_field_type, reasoning_result[:history])
421
+
422
+ output_data[output_field_name] = deserialized_value
423
+
424
+ @enhanced_output_struct.new(**output_data)
425
+ end
426
+
427
+ # Find the most recent non-nil tool observation in history
428
+ sig { params(history: T::Array[HistoryEntry]).returns(T.untyped) }
429
+ def find_last_tool_observation(history)
430
+ history.reverse.find { |entry| !entry.observation.nil? }&.observation
431
+ end
432
+
433
+ # Deserialize final answer to match expected output type
434
+ # Routes to appropriate deserialization based on type classification
435
+ sig { params(final_answer: T.untyped, output_field_type: T.untyped, history: T::Array[HistoryEntry]).returns(T.untyped) }
436
+ def deserialize_final_answer(final_answer, output_field_type, history)
437
+ if scalar_type?(output_field_type)
438
+ deserialize_scalar(final_answer, output_field_type)
439
+ elsif structured_type?(output_field_type)
440
+ deserialize_structured(final_answer, output_field_type, history)
385
441
  else
386
- output_data[output_field_name] = final_answer
442
+ # Fallback for unknown types
443
+ return final_answer if type_matches?(final_answer, output_field_type)
444
+ convert_to_expected_type(final_answer, output_field_type)
387
445
  end
446
+ end
388
447
 
389
- @enhanced_output_struct.new(**output_data)
448
+ # Deserialize scalar types (String, Integer, Boolean, etc.)
449
+ # Scalars: Trust LLM synthesis, minimal conversion
450
+ sig { params(final_answer: T.untyped, output_field_type: T.untyped).returns(T.untyped) }
451
+ def deserialize_scalar(final_answer, output_field_type)
452
+ # If already matches, return as-is (even if empty string for String types)
453
+ return final_answer if type_matches?(final_answer, output_field_type)
454
+
455
+ # Try basic conversion
456
+ converted = convert_to_expected_type(final_answer, output_field_type)
457
+ return converted if type_matches?(converted, output_field_type)
458
+
459
+ # Type mismatch - raise error with helpful message
460
+ expected_type = type_name(output_field_type)
461
+ actual_type = final_answer.class.name
462
+ raise TypeMismatchError, "Cannot convert final answer from #{actual_type} to #{expected_type}. Value: #{final_answer.inspect}"
463
+ end
464
+
465
+ # Deserialize structured types (arrays, hashes, structs)
466
+ # Structured: Prefer tool observation to preserve type information
467
+ sig { params(final_answer: T.untyped, output_field_type: T.untyped, history: T::Array[HistoryEntry]).returns(T.untyped) }
468
+ def deserialize_structured(final_answer, output_field_type, history)
469
+ # First, try to use the last tool observation if it matches the expected type
470
+ # This preserves type information that would be lost in LLM synthesis
471
+ last_tool_observation = find_last_tool_observation(history)
472
+ if last_tool_observation && type_matches?(last_tool_observation, output_field_type)
473
+ return last_tool_observation
474
+ end
475
+
476
+ # If final_answer already matches, use it
477
+ return final_answer if type_matches?(final_answer, output_field_type)
478
+
479
+ # Try to convert based on expected type
480
+ converted = convert_to_expected_type(final_answer, output_field_type)
481
+ return converted if type_matches?(converted, output_field_type)
482
+
483
+ # Type mismatch - raise error with helpful message
484
+ expected_type = type_name(output_field_type)
485
+ actual_type = final_answer.class.name
486
+ raise TypeMismatchError, "Cannot convert final answer from #{actual_type} to #{expected_type}. Value: #{final_answer.inspect}"
487
+ end
488
+
489
+ # Convert value to expected type
490
+ sig { params(value: T.untyped, type_object: T.untyped).returns(T.untyped) }
491
+ def convert_to_expected_type(value, type_object)
492
+ case type_object
493
+ when T::Types::TypedArray
494
+ return value unless value.is_a?(Array)
495
+ element_type = type_object.type
496
+ value.map { |item| convert_to_expected_type(item, element_type) }
497
+ when T::Types::Simple
498
+ struct_class = type_object.raw_type
499
+ if struct_class < T::Struct && value.is_a?(Hash)
500
+ # Convert string keys to symbol keys
501
+ symbolized = value.transform_keys(&:to_sym)
502
+ struct_class.new(**symbolized)
503
+ else
504
+ value
505
+ end
506
+ else
507
+ value
508
+ end
509
+ end
510
+
511
+ # Check if a value matches the expected type
512
+ sig { params(value: T.untyped, type_object: T.untyped).returns(T::Boolean) }
513
+ def type_matches?(value, type_object)
514
+ case type_object
515
+ when T::Types::TypedArray
516
+ value.is_a?(Array) && (value.empty? || value.first.is_a?(T::Struct))
517
+ when T::Types::TypedHash
518
+ value.is_a?(Hash)
519
+ when T::Types::Simple
520
+ value.is_a?(type_object.raw_type)
521
+ when T::Types::Union
522
+ # For union types, check if value matches any of the types
523
+ type_object.types.any? { |t| type_matches?(value, t) }
524
+ else
525
+ false
526
+ end
390
527
  end
391
528
 
392
529
  # Helper methods for ReAct logic
393
- sig { params(iterations_count: Integer, final_answer: T.nilable(String)).returns(T::Boolean) }
530
+ sig { params(iterations_count: Integer, final_answer: T.untyped).returns(T::Boolean) }
394
531
  def should_continue_iteration?(iterations_count, final_answer)
395
532
  final_answer.nil? && (@max_iterations.nil? || iterations_count < @max_iterations)
396
533
  end
@@ -409,29 +546,30 @@ module DSPy
409
546
  !!@tools[action_str.downcase]
410
547
  end
411
548
 
412
- sig { params(action: T.nilable(T.any(String, T::Enum)), action_input: T.untyped, iteration: Integer).returns(String) }
549
+ sig { params(action: T.nilable(T.any(String, T::Enum)), action_input: T.untyped, iteration: Integer).returns(T.untyped) }
413
550
  def execute_tool_with_instrumentation(action, action_input, iteration)
414
- return "Unknown action: #{action}. Available actions: #{@tools.keys.join(', ')}, finish" unless action
415
-
551
+ raise InvalidActionError, "No action provided" unless action
552
+
416
553
  action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
417
-
418
- if @tools[action_str.downcase]
419
- DSPy::Context.with_span(
420
- operation: 'react.tool_call',
421
- **DSPy::ObservationType::Tool.langfuse_attributes,
422
- 'dspy.module' => 'ReAct',
423
- 'react.iteration' => iteration,
424
- 'tool.name' => action_str.downcase,
425
- 'tool.input' => action_input
426
- ) do
427
- execute_action(action_str, action_input)
428
- end
429
- else
430
- "Unknown action: #{action_str}. Available actions: #{@tools.keys.join(', ')}, finish"
554
+
555
+ unless @tools[action_str.downcase]
556
+ available = @tools.keys.join(', ')
557
+ raise InvalidActionError, "Unknown action: #{action_str}. Available actions: #{available}, finish"
558
+ end
559
+
560
+ DSPy::Context.with_span(
561
+ operation: 'react.tool_call',
562
+ **DSPy::ObservationType::Tool.langfuse_attributes,
563
+ 'dspy.module' => 'ReAct',
564
+ 'react.iteration' => iteration,
565
+ 'tool.name' => action_str.downcase,
566
+ 'tool.input' => action_input
567
+ ) do
568
+ execute_action(action_str, action_input)
431
569
  end
432
570
  end
433
571
 
434
- sig { params(step: Integer, thought: String, action: String, action_input: T.untyped, observation: String).returns(HistoryEntry) }
572
+ sig { params(step: Integer, thought: String, action: String, action_input: T.untyped, observation: T.untyped).returns(HistoryEntry) }
435
573
  def create_history_entry(step, thought, action, action_input, observation)
436
574
  HistoryEntry.new(
437
575
  step: step,
@@ -442,14 +580,12 @@ module DSPy
442
580
  )
443
581
  end
444
582
 
445
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[AvailableTool], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
583
+ 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]) }
446
584
  def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration)
447
- return { should_finish: false } if observation.include?("Unknown action")
448
-
449
585
  observation_result = @observation_processor.forward(
450
586
  input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
451
- history: history,
452
- observation: observation
587
+ history: serialize_history_for_llm(history),
588
+ observation: serialize_for_llm(observation)
453
589
  )
454
590
 
455
591
  return { should_finish: false } unless observation_result.next_step == NextStep::Finish
@@ -461,20 +597,23 @@ module DSPy
461
597
  { should_finish: true, final_answer: final_answer }
462
598
  end
463
599
 
464
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], observation_result: T.untyped, iteration: Integer).returns(String) }
600
+ 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) }
465
601
  def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
466
602
  final_thought = @thought_generator.forward(
467
603
  input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
468
- history: history,
604
+ history: serialize_history_for_llm(history),
469
605
  available_tools: available_tools_desc
470
606
  )
471
607
 
472
608
  action_str = final_thought.action.respond_to?(:serialize) ? final_thought.action.serialize : final_thought.action.to_s
473
609
  if action_str.downcase != FINISH_ACTION
610
+ # Use interpretation if available, otherwise use last observation
474
611
  forced_answer = if observation_result.interpretation && !observation_result.interpretation.empty?
475
612
  observation_result.interpretation
613
+ elsif history.last&.observation
614
+ history.last.observation
476
615
  else
477
- history.last&.observation || "No answer available"
616
+ raise MaxIterationsError, "Observation processor indicated finish but no answer is available"
478
617
  end
479
618
  handle_finish_action(forced_answer, history.last&.observation, iteration + 1, final_thought.thought, FINISH_ACTION, history)
480
619
  else
@@ -482,7 +621,7 @@ module DSPy
482
621
  end
483
622
  end
484
623
 
485
- sig { params(iteration: Integer, thought: String, action: String, action_input: T.untyped, observation: String, tools_used: T::Array[String]).void }
624
+ sig { params(iteration: Integer, thought: String, action: String, action_input: T.untyped, observation: T.untyped, tools_used: T::Array[String]).void }
486
625
  def emit_iteration_complete_event(iteration, thought, action, action_input, observation, tools_used)
487
626
  DSPy.event('react.iteration_complete', {
488
627
  'react.iteration' => iteration,
@@ -494,7 +633,7 @@ module DSPy
494
633
  })
495
634
  end
496
635
 
497
- sig { params(iterations_count: Integer, final_answer: T.nilable(String), tools_used: T::Array[String], history: T::Array[HistoryEntry]).void }
636
+ sig { params(iterations_count: Integer, final_answer: T.untyped, tools_used: T::Array[String], history: T::Array[HistoryEntry]).void }
498
637
  def handle_max_iterations_if_needed(iterations_count, final_answer, tools_used, history)
499
638
  if iterations_count >= @max_iterations && final_answer.nil?
500
639
  DSPy.event('react.max_iterations', {
@@ -506,14 +645,44 @@ module DSPy
506
645
  end
507
646
  end
508
647
 
509
- sig { returns(String) }
510
- def default_no_answer_message
511
- "No answer reached within #{@max_iterations} iterations"
648
+ # Checks if a type is a scalar (primitives that don't need special serialization)
649
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
650
+ def scalar_type?(type_object)
651
+ case type_object
652
+ when T::Types::Simple
653
+ scalar_classes = [String, Integer, Float, Numeric, TrueClass, FalseClass]
654
+ scalar_classes.any? { |klass| type_object.raw_type == klass || type_object.raw_type <= klass }
655
+ when T::Types::Union
656
+ # Union is scalar if all its types are scalars
657
+ type_object.types.all? { |t| scalar_type?(t) }
658
+ else
659
+ false
660
+ end
661
+ end
662
+
663
+ # Checks if a type is structured (arrays, hashes, structs that need type preservation)
664
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
665
+ def structured_type?(type_object)
666
+ return true if type_object.is_a?(T::Types::TypedArray)
667
+ return true if type_object.is_a?(T::Types::TypedHash)
668
+
669
+ if type_object.is_a?(T::Types::Simple)
670
+ raw_type = type_object.raw_type
671
+ return true if raw_type.respond_to?(:<=) && raw_type <= T::Struct
672
+ end
673
+
674
+ # For union types (like T.nilable(T::Array[...])), check if any non-nil type is structured
675
+ if type_object.is_a?(T::Types::Union)
676
+ non_nil_types = type_object.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
677
+ return non_nil_types.any? { |t| structured_type?(t) }
678
+ end
679
+
680
+ false
512
681
  end
513
682
 
514
683
  # Checks if a type is String or compatible with String (e.g., T.any(String, ...) or T.nilable(String))
515
684
  sig { params(type_object: T.untyped).returns(T::Boolean) }
516
- def string_compatible_type?(type_object)
685
+ def string_type?(type_object)
517
686
  case type_object
518
687
  when T::Types::Simple
519
688
  type_object.raw_type == String
@@ -525,6 +694,28 @@ module DSPy
525
694
  end
526
695
  end
527
696
 
697
+ # Alias for backward compatibility
698
+ alias string_compatible_type? string_type?
699
+
700
+ # Get a readable type name from a Sorbet type object
701
+ sig { params(type_object: T.untyped).returns(String) }
702
+ def type_name(type_object)
703
+ case type_object
704
+ when T::Types::TypedArray
705
+ element_type = type_object.type
706
+ "T::Array[#{type_name(element_type)}]"
707
+ when T::Types::TypedHash
708
+ "T::Hash"
709
+ when T::Types::Simple
710
+ type_object.raw_type.to_s
711
+ when T::Types::Union
712
+ types_str = type_object.types.map { |t| type_name(t) }.join(', ')
713
+ "T.any(#{types_str})"
714
+ else
715
+ type_object.to_s
716
+ end
717
+ end
718
+
528
719
  # Returns an appropriate default value for a given Sorbet type
529
720
  # This is used when max iterations is reached without a successful completion
530
721
  sig { params(type_object: T.untyped).returns(T.untyped) }
@@ -562,25 +753,19 @@ module DSPy
562
753
  end
563
754
 
564
755
  # Tool execution method
565
- sig { params(action: String, action_input: T.untyped).returns(String) }
756
+ sig { params(action: String, action_input: T.untyped).returns(T.untyped) }
566
757
  def execute_action(action, action_input)
567
758
  tool_name = action.downcase
568
759
  tool = @tools[tool_name]
569
- return "Tool '#{action}' not found. Available tools: #{@tools.keys.join(', ')}" unless tool
570
760
 
571
- begin
572
- result = if action_input.nil? ||
573
- (action_input.is_a?(String) && action_input.strip.empty?)
574
- # No input provided
575
- tool.dynamic_call({})
576
- else
577
- # Pass the action_input directly to dynamic_call, which can handle
578
- # either a Hash or a JSON string
579
- tool.dynamic_call(action_input)
580
- end
581
- result.to_s
582
- rescue => e
583
- "Error executing tool '#{action}': #{e.message}"
761
+ # This should not happen since we check in execute_tool_with_instrumentation
762
+ raise InvalidActionError, "Tool '#{action}' not found" unless tool
763
+
764
+ # Execute tool - let errors propagate
765
+ if action_input.nil? || (action_input.is_a?(String) && action_input.strip.empty?)
766
+ tool.dynamic_call({})
767
+ else
768
+ tool.dynamic_call(action_input)
584
769
  end
585
770
  end
586
771
 
@@ -629,12 +814,12 @@ module DSPy
629
814
  example
630
815
  end
631
816
 
632
- sig { params(action_input: T.untyped, last_observation: T.nilable(String), step: Integer, thought: String, action: T.any(String, T::Enum), history: T::Array[HistoryEntry]).returns(String) }
817
+ sig { params(action_input: T.untyped, last_observation: T.untyped, step: Integer, thought: String, action: T.any(String, T::Enum), history: T::Array[HistoryEntry]).returns(T.untyped) }
633
818
  def handle_finish_action(action_input, last_observation, step, thought, action, history)
634
- final_answer = action_input.to_s
819
+ final_answer = action_input
635
820
 
636
- # If final_answer is empty but we have a last observation, use it
637
- if (final_answer.nil? || final_answer.empty?) && last_observation
821
+ # If final_answer is empty/nil but we have a last observation, use it
822
+ if (final_answer.nil? || (final_answer.is_a?(String) && final_answer.empty?)) && last_observation
638
823
  final_answer = last_observation
639
824
  end
640
825