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