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.
- checksums.yaml +4 -4
- data/README.md +483 -3
- data/lib/dspy/chain_of_thought.rb +162 -0
- data/lib/dspy/field.rb +23 -0
- data/lib/dspy/instrumentation/token_tracker.rb +54 -0
- data/lib/dspy/instrumentation.rb +100 -0
- data/lib/dspy/lm/adapter.rb +41 -0
- data/lib/dspy/lm/adapter_factory.rb +59 -0
- data/lib/dspy/lm/adapters/anthropic_adapter.rb +96 -0
- data/lib/dspy/lm/adapters/openai_adapter.rb +53 -0
- data/lib/dspy/lm/adapters/ruby_llm_adapter.rb +81 -0
- data/lib/dspy/lm/errors.rb +10 -0
- data/lib/dspy/lm/response.rb +28 -0
- data/lib/dspy/lm.rb +128 -0
- data/lib/dspy/module.rb +58 -0
- data/lib/dspy/predict.rb +192 -0
- data/lib/dspy/re_act.rb +428 -0
- data/lib/dspy/schema_adapters.rb +55 -0
- data/lib/dspy/signature.rb +298 -0
- data/lib/dspy/subscribers/logger_subscriber.rb +197 -0
- data/lib/dspy/tools/base.rb +226 -0
- data/lib/dspy/tools.rb +21 -0
- data/lib/dspy.rb +38 -2
- metadata +150 -4
data/lib/dspy/module.rb
ADDED
@@ -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
|
data/lib/dspy/predict.rb
ADDED
@@ -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
|
data/lib/dspy/re_act.rb
ADDED
@@ -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
|