dspy 0.34.3 → 0.34.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01f38786c88d525a1031cf41931f578c3d2dcbfa29ee6a8dac1a381cafe47edf
4
- data.tar.gz: 6334bfb483b3011fa91e163f688127be763a126ea7cd0edc44f07b0557dc2a30
3
+ metadata.gz: 5d5296e0130d0550156345659e6703451bd6ca6fb9ffbac33d8584ee203f2a84
4
+ data.tar.gz: 339772eb768a2babbb8b700b868fc772183473b7cf4f8a6867e55945f75a3655
5
5
  SHA512:
6
- metadata.gz: 744087dd87e936b247d194539407f2a74b29d5e6a28b4ba872c4aa0ef77103c4a6957c97b6bed3ee7e8ef899824f3e6e0f40c2b429c47312aa10924bb1fbca3c
7
- data.tar.gz: 4e343687e84570d199ce9c7695d19d0a0a551cac66693fda131fe03268d3907e2d20f4648530d1e6a5de0a73092b03f3ec7bcec877d9c23662332193aaee0e31
6
+ metadata.gz: 827360cba1ad8d03373d40d9b1b9ce3acf966e896261b918537150568cf4635904e591c238cdea7d8e8bf45980851ce3c581f709639215c8d7e4c7e1ea78dc08
7
+ data.tar.gz: e8d91df3e7204ac0d0db830c5839a4cbdd9683a00be2a77dec38de156052f0a0b3956e25c8b7026fcce23880e9806fc9af023fc2a4fe830926f288f82474a85b
data/README.md CHANGED
@@ -137,26 +137,18 @@ result.answer # => "60 km/h"
137
137
  Build agents that use tools to accomplish tasks:
138
138
 
139
139
  ```ruby
140
- class SearchTool < DSPy::Tools::Tool
140
+ class SearchTool < DSPy::Tools::Base
141
141
  tool_name "search"
142
- description "Search for information"
143
-
144
- input do
145
- const :query, String
146
- end
147
-
148
- output do
149
- const :results, T::Array[String]
150
- end
142
+ tool_description "Search for information"
151
143
 
144
+ sig { params(query: String).returns(String) }
152
145
  def call(query:)
153
146
  # Your search implementation
154
- { results: ["Result 1", "Result 2"] }
147
+ "Result 1, Result 2"
155
148
  end
156
149
  end
157
150
 
158
- toolset = DSPy::Tools::Toolset.new(tools: [SearchTool.new])
159
- agent = DSPy::ReAct.new(signature: ResearchTask, tools: toolset, max_iterations: 5)
151
+ agent = DSPy::ReAct.new(ResearchTask, tools: [SearchTool.new], max_iterations: 5)
160
152
  result = agent.call(question: "What's the latest on Ruby 3.4?")
161
153
  ```
162
154
 
@@ -185,8 +177,8 @@ result = agent.call(question: "What's the latest on Ruby 3.4?")
185
177
  A [Claude Skill](https://github.com/vicentereig/dspy-rb-skill) is available to help you build DSPy.rb applications:
186
178
 
187
179
  ```bash
188
- # Claude Code
189
- git clone https://github.com/vicentereig/dspy-rb-skill ~/.claude/skills/dspy-rb
180
+ # Claude Code — install from the vicentereig/engineering marketplace
181
+ claude install-skill vicentereig/engineering --skill dspy-rb
190
182
  ```
191
183
 
192
184
  For Claude.ai Pro/Max, download the [skill ZIP](https://github.com/vicentereig/dspy-rb-skill/archive/refs/heads/main.zip) and upload via Settings > Skills.
@@ -201,7 +193,7 @@ The [examples/](examples/) directory has runnable code for common patterns:
201
193
  - Prompt optimization
202
194
 
203
195
  ```bash
204
- bundle exec ruby examples/first_predictor.rb
196
+ bundle exec ruby examples/basic_search_agent.rb
205
197
  ```
206
198
 
207
199
  ## Optional Gems
data/lib/dspy/context.rb CHANGED
@@ -74,8 +74,9 @@ module DSPy
74
74
  # Prepare attributes and add trace name for root spans
75
75
  span_attributes = sanitized_attributes.transform_keys(&:to_s).reject { |k, v| v.nil? }
76
76
 
77
- # Set trace name if this is likely a root span (no parent in our stack)
78
- if current[:span_stack].length == 1 # This will be the first span
77
+ # Set trace name if this is likely a root span (no parent in our stack),
78
+ # unless callers already specified one explicitly.
79
+ if current[:span_stack].length == 1 && !span_attributes.key?('langfuse.trace.name')
79
80
  span_attributes['langfuse.trace.name'] = operation
80
81
  end
81
82
 
@@ -84,6 +85,12 @@ module DSPy
84
85
 
85
86
  # Get parent OpenTelemetry span for proper context propagation
86
87
  parent_otel_span = current[:otel_span_stack].last
88
+ if !parent_otel_span && defined?(OpenTelemetry::Trace)
89
+ current_span = OpenTelemetry::Trace.current_span
90
+ if current_span && current_span != OpenTelemetry::Trace::Span::INVALID
91
+ parent_otel_span = current_span
92
+ end
93
+ end
87
94
 
88
95
  # Create span with proper parent context
89
96
  if parent_otel_span
@@ -96,20 +103,18 @@ module DSPy
96
103
  ) do |span|
97
104
  # Add to our OpenTelemetry span stack
98
105
  current[:otel_span_stack].push(span)
106
+ succeeded = false
99
107
 
100
108
  begin
101
109
  result = yield(span)
102
-
103
- # Add explicit timing information to help Langfuse
104
- if span
105
- duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
106
- span.set_attribute('duration.ms', duration_ms)
107
- span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
108
- span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
109
- end
110
-
110
+ succeeded = true
111
111
  result
112
+ rescue StandardError => e
113
+ set_span_error_attributes(span, e)
114
+ raise
112
115
  ensure
116
+ set_span_status_attribute(span, succeeded)
117
+ set_span_timing_attributes(span, otel_start_time)
113
118
  # Remove from our OpenTelemetry span stack
114
119
  current[:otel_span_stack].pop
115
120
  end
@@ -124,20 +129,18 @@ module DSPy
124
129
  ) do |span|
125
130
  # Add to our OpenTelemetry span stack
126
131
  current[:otel_span_stack].push(span)
132
+ succeeded = false
127
133
 
128
134
  begin
129
135
  result = yield(span)
130
-
131
- # Add explicit timing information to help Langfuse
132
- if span
133
- duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
134
- span.set_attribute('duration.ms', duration_ms)
135
- span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
136
- span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
137
- end
138
-
136
+ succeeded = true
139
137
  result
138
+ rescue StandardError => e
139
+ set_span_error_attributes(span, e)
140
+ raise
140
141
  ensure
142
+ set_span_status_attribute(span, succeeded)
143
+ set_span_timing_attributes(span, otel_start_time)
141
144
  # Remove from our OpenTelemetry span stack
142
145
  current[:otel_span_stack].pop
143
146
  end
@@ -296,6 +299,36 @@ module DSPy
296
299
  label: explicit_label || (module_instance.respond_to?(:module_scope_label) ? module_instance.module_scope_label : nil)
297
300
  }
298
301
  end
302
+
303
+ def set_span_timing_attributes(span, otel_start_time)
304
+ return unless span
305
+
306
+ now = Time.now
307
+ duration_ms = ((now - otel_start_time) * 1000).round(3)
308
+ span.set_attribute('duration.ms', duration_ms)
309
+ span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
310
+ span.set_attribute('langfuse.observation.endTime', now.iso8601(3))
311
+ rescue StandardError
312
+ nil
313
+ end
314
+
315
+ def set_span_error_attributes(span, error)
316
+ return unless span
317
+
318
+ span.set_attribute('error', true)
319
+ span.set_attribute('error.type', error.class.name)
320
+ span.set_attribute('error.message', error.message.to_s[0, 2000]) if error.message
321
+ rescue StandardError
322
+ nil
323
+ end
324
+
325
+ def set_span_status_attribute(span, succeeded)
326
+ return unless span
327
+
328
+ span.set_attribute('dspy.status', succeeded ? 'completed' : 'error')
329
+ rescue StandardError
330
+ nil
331
+ end
299
332
  end
300
333
  end
301
334
  end
@@ -38,17 +38,8 @@ module DSPy
38
38
  # OpenAI/Ollama: try to extract JSON from various formats
39
39
  extract_json_from_content(response.content)
40
40
  elsif adapter_class_name.include?('AnthropicAdapter')
41
- # Anthropic: try tool use first if structured_outputs enabled, else use content extraction
42
- structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
43
- structured_outputs_enabled = true if structured_outputs_enabled.nil? # Default to true
44
-
45
- if structured_outputs_enabled
46
- extracted = extract_anthropic_tool_json(response)
47
- extracted || extract_json_from_content(response.content)
48
- else
49
- # Skip tool extraction, use enhanced prompting extraction
50
- extract_json_from_content(response.content)
51
- end
41
+ # Anthropic: Beta API returns JSON in content, same as OpenAI/Gemini
42
+ extract_json_from_content(response.content)
52
43
  elsif adapter_class_name.include?('GeminiAdapter')
53
44
  # Gemini: try to extract JSON from various formats
54
45
  extract_json_from_content(response.content)
@@ -90,25 +81,30 @@ module DSPy
90
81
  # Anthropic preparation
91
82
  sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
92
83
  def prepare_anthropic_request(messages, request_params)
93
- # Only use tool-based extraction if structured_outputs is enabled (default: true)
94
- structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
84
+ begin
85
+ require "dspy/anthropic/lm/schema_converter"
86
+ rescue LoadError
87
+ msg = <<~MSG
88
+ Anthropic adapter is optional; structured output helpers will be unavailable until the gem is installed.
89
+ Add `gem 'dspy-anthropic'` to your Gemfile and run `bundle install`.
90
+ MSG
91
+ raise DSPy::LM::MissingAdapterError, msg
92
+ end
95
93
 
96
- # Default to true if not set (backward compatibility)
94
+ # Only use Beta API structured outputs if enabled (default: true)
95
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
97
96
  structured_outputs_enabled = true if structured_outputs_enabled.nil?
98
97
 
99
98
  return unless structured_outputs_enabled
100
99
 
101
- # Convert signature to tool schema
102
- tool_schema = convert_to_anthropic_tool_schema
103
-
104
- # Add tool definition
105
- request_params[:tools] = [tool_schema]
100
+ # Use Anthropic Beta API structured outputs
101
+ schema = DSPy::Anthropic::LM::SchemaConverter.to_beta_format(signature_class)
106
102
 
107
- # Force tool use
108
- request_params[:tool_choice] = {
109
- type: "tool",
110
- name: "json_output"
111
- }
103
+ request_params[:output_format] = ::Anthropic::Models::Beta::BetaJSONOutputFormat.new(
104
+ type: :json_schema,
105
+ schema: schema
106
+ )
107
+ request_params[:betas] = ["structured-outputs-2025-11-13"]
112
108
  end
113
109
 
114
110
  # Gemini preparation
@@ -135,89 +131,19 @@ module DSPy
135
131
  end
136
132
  end
137
133
 
138
- # Convert signature to Anthropic tool schema
139
- # Uses strict: true for constrained decoding (Anthropic structured outputs)
140
- # Anthropic strict mode requires ALL properties in required at every level.
141
- sig { returns(T::Hash[Symbol, T.untyped]) }
142
- def convert_to_anthropic_tool_schema
143
- output_fields = signature_class.output_field_descriptors
144
-
145
- schema = {
146
- name: "json_output",
147
- description: "Output the result in the required JSON format",
148
- strict: true,
149
- input_schema: {
150
- type: "object",
151
- properties: build_properties_from_fields(output_fields),
152
- required: build_required_from_fields(output_fields),
153
- additionalProperties: false
154
- }
155
- }
156
-
157
- # Anthropic strict mode: ALL properties must be in required at every level.
158
- # Non-required properties get auto-wrapped in null unions by the grammar compiler,
159
- # which counts against the 16-union-parameter limit.
160
- enforce_all_required(schema[:input_schema])
161
-
162
- schema
163
- end
164
-
165
- # Build required field list, excluding fields that have defaults
166
- sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
167
- def build_required_from_fields(fields)
168
- fields.reject { |_name, descriptor| descriptor.has_default }.keys.map(&:to_s)
169
- end
170
-
171
- # Recursively enforce that all properties are in required and
172
- # additionalProperties is false, as required by Anthropic strict mode.
173
- sig { params(schema: T::Hash[Symbol, T.untyped]).void }
174
- def enforce_all_required(schema)
175
- return unless schema.is_a?(Hash)
176
-
177
- if schema[:type] == "object" && schema[:properties]
178
- schema[:required] = schema[:properties].keys.map(&:to_s)
179
- schema[:additionalProperties] = false
180
- schema[:properties].each_value { |v| enforce_all_required(v) }
181
- elsif schema[:type] == "array" && schema[:items]
182
- enforce_all_required(schema[:items])
183
- elsif schema[:type].is_a?(Array)
184
- # type: ["array", "null"] — check items if present
185
- enforce_all_required(schema[:items]) if schema[:items]
186
- end
187
- end
188
-
189
- # Build JSON schema properties from output fields
190
- sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
191
- def build_properties_from_fields(fields)
192
- properties = {}
193
- fields.each do |field_name, descriptor|
194
- properties[field_name.to_s] = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
195
- end
196
- properties
197
- end
198
-
199
- # Extract JSON from Anthropic tool use response
200
- sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
201
- def extract_anthropic_tool_json(response)
202
- # Check for tool calls in metadata
203
- if response.metadata.respond_to?(:tool_calls) && response.metadata.tool_calls
204
- tool_calls = response.metadata.tool_calls
205
- if tool_calls.is_a?(Array) && !tool_calls.empty?
206
- first_call = tool_calls.first
207
- if first_call[:name] == "json_output" && first_call[:input]
208
- return JSON.generate(first_call[:input])
209
- end
210
- end
211
- end
212
-
213
- nil
214
- end
215
-
216
134
  # Extract JSON from content that may contain markdown or plain JSON
217
135
  sig { params(content: String).returns(String) }
218
136
  def extract_json_from_content(content)
219
137
  return content if content.nil? || content.empty?
220
138
 
139
+ # Fix Anthropic Beta API bug with optional fields producing invalid JSON
140
+ # When some output fields are optional and not returned, Anthropic's structured outputs
141
+ # can produce trailing comma+brace: {"field1": {...},} instead of {"field1": {...}}
142
+ # This workaround removes the invalid trailing syntax before JSON parsing
143
+ if content =~ /,\s*\}\s*$/
144
+ content = content.sub(/,(\s*\}\s*)$/, '\1')
145
+ end
146
+
221
147
  # Try 1: Check for ```json code block (with or without preceding text)
222
148
  if content.include?('```json')
223
149
  json_match = content.match(/```json\s*\n(.*?)\n```/m)
@@ -118,7 +118,7 @@ module DSPy
118
118
  extend T::Sig
119
119
 
120
120
  const :content, String
121
- const :usage, T.nilable(T.any(Usage, OpenAIUsage)), default: nil
121
+ const :usage, T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage)), default: nil
122
122
  const :metadata, T.any(ResponseMetadata, OpenAIResponseMetadata, AnthropicResponseMetadata, GeminiResponseMetadata, T::Hash[Symbol, T.untyped])
123
123
 
124
124
  sig { returns(String) }
data/lib/dspy/lm/usage.rb CHANGED
@@ -45,11 +45,34 @@ module DSPy
45
45
  end
46
46
  end
47
47
 
48
+ # Anthropic-specific usage information with cache token fields
49
+ class AnthropicUsage < T::Struct
50
+ extend T::Sig
51
+
52
+ const :input_tokens, Integer
53
+ const :output_tokens, Integer
54
+ const :total_tokens, Integer
55
+ const :cache_creation_input_tokens, T.nilable(Integer), default: nil
56
+ const :cache_read_input_tokens, T.nilable(Integer), default: nil
57
+
58
+ sig { returns(Hash) }
59
+ def to_h
60
+ base = {
61
+ input_tokens: input_tokens,
62
+ output_tokens: output_tokens,
63
+ total_tokens: total_tokens
64
+ }
65
+ base[:cache_creation_input_tokens] = cache_creation_input_tokens unless cache_creation_input_tokens.nil?
66
+ base[:cache_read_input_tokens] = cache_read_input_tokens unless cache_read_input_tokens.nil?
67
+ base
68
+ end
69
+ end
70
+
48
71
  # Factory for creating appropriate usage objects
49
72
  module UsageFactory
50
73
  extend T::Sig
51
74
 
52
- sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage))) }
75
+ sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage))) }
53
76
  def self.create(provider, usage_data)
54
77
  return nil if usage_data.nil?
55
78
 
@@ -121,17 +144,19 @@ module DSPy
121
144
  nil
122
145
  end
123
146
 
124
- sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(Usage)) }
147
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(AnthropicUsage)) }
125
148
  def self.create_anthropic_usage(data)
126
149
  # Anthropic uses input_tokens/output_tokens
127
150
  input_tokens = data[:input_tokens] || 0
128
151
  output_tokens = data[:output_tokens] || 0
129
152
  total_tokens = data[:total_tokens] || (input_tokens + output_tokens)
130
-
131
- Usage.new(
153
+
154
+ AnthropicUsage.new(
132
155
  input_tokens: input_tokens,
133
156
  output_tokens: output_tokens,
134
- total_tokens: total_tokens
157
+ total_tokens: total_tokens,
158
+ cache_creation_input_tokens: data[:cache_creation_input_tokens],
159
+ cache_read_input_tokens: data[:cache_read_input_tokens]
135
160
  )
136
161
  rescue StandardError => e
137
162
  DSPy.logger.debug("Failed to create Anthropic usage: #{e.message}")
@@ -173,4 +198,4 @@ module DSPy
173
198
  end
174
199
  end
175
200
  end
176
- end
201
+ end
data/lib/dspy/lm.rb CHANGED
@@ -305,6 +305,12 @@ module DSPy
305
305
  span.set_attribute('gen_ai.usage.prompt_tokens', usage.input_tokens) if usage.input_tokens
306
306
  span.set_attribute('gen_ai.usage.completion_tokens', usage.output_tokens) if usage.output_tokens
307
307
  span.set_attribute('gen_ai.usage.total_tokens', usage.total_tokens) if usage.total_tokens
308
+ if usage.respond_to?(:cache_creation_input_tokens) && !usage.cache_creation_input_tokens.nil?
309
+ span.set_attribute('gen_ai.usage.cache_creation_input_tokens', usage.cache_creation_input_tokens)
310
+ end
311
+ if usage.respond_to?(:cache_read_input_tokens) && !usage.cache_read_input_tokens.nil?
312
+ span.set_attribute('gen_ai.usage.cache_read_input_tokens', usage.cache_read_input_tokens)
313
+ end
308
314
  end
309
315
  end
310
316
 
@@ -356,11 +362,16 @@ module DSPy
356
362
 
357
363
  # Handle Usage struct objects
358
364
  if response.usage.respond_to?(:input_tokens)
359
- return {
365
+ result = {
360
366
  input_tokens: response.usage.input_tokens,
361
367
  output_tokens: response.usage.output_tokens,
362
368
  total_tokens: response.usage.total_tokens
363
- }.compact
369
+ }
370
+ if response.usage.respond_to?(:cache_creation_input_tokens)
371
+ result[:cache_creation_input_tokens] = response.usage.cache_creation_input_tokens
372
+ result[:cache_read_input_tokens] = response.usage.cache_read_input_tokens
373
+ end
374
+ return result.compact
364
375
  end
365
376
 
366
377
  # Handle hash-based usage (for VCR compatibility)
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'sorbet-runtime'
5
+ require 'yaml'
5
6
 
6
7
  module DSPy
7
8
  module Mixins
@@ -88,6 +89,15 @@ module DSPy
88
89
  case prop_type
89
90
  when ->(type) { union_type?(type) }
90
91
  coerce_union_value(value, prop_type)
92
+ when ->(type) { nilable_type?(type) }
93
+ # Unwrap T.nilable(X) to coerce as X (nil already handled above)
94
+ non_nil_types = prop_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
95
+ if non_nil_types.size == 1
96
+ coerce_value_to_type(value, non_nil_types.first)
97
+ else
98
+ # T.any(A, B, NilClass) — rebuild as T.any(A, B) and coerce as union
99
+ coerce_union_value(value, T::Types::Union.new(non_nil_types))
100
+ end
91
101
  when ->(type) { array_type?(type) }
92
102
  coerce_array_value(value, prop_type)
93
103
  when ->(type) { hash_type?(type) }
@@ -161,15 +171,31 @@ module DSPy
161
171
  # Checks if a type is a union type (T.any)
162
172
  sig { params(type: T.untyped).returns(T::Boolean) }
163
173
  def union_type?(type)
164
- type.is_a?(T::Types::Union) && !is_nilable_type?(type)
174
+ type.is_a?(T::Types::Union) && !nilable_type?(type)
165
175
  end
166
176
 
167
177
  # Checks if a type is nilable (contains NilClass)
168
178
  sig { params(type: T.untyped).returns(T::Boolean) }
169
- def is_nilable_type?(type)
179
+ def nilable_type?(type)
170
180
  type.is_a?(T::Types::Union) && type.types.any? { |t| t == T::Utils.coerce(NilClass) }
171
181
  end
172
182
 
183
+ # Checks if a union type is a simple nilable struct (T.nilable(SomeStruct))
184
+ # Returns true only if the union has exactly 2 types: NilClass and a Struct
185
+ sig { params(union_type: T.untyped).returns(T::Boolean) }
186
+ def nilable_struct_union?(union_type)
187
+ return false unless union_type.is_a?(T::Types::Union)
188
+
189
+ types = union_type.types
190
+ return false unless types.size == 2
191
+
192
+ # One type must be NilClass, the other must be a struct
193
+ has_nil = types.any? { |t| t == T::Utils.coerce(NilClass) }
194
+ struct_type = types.find { |t| t != T::Utils.coerce(NilClass) && struct_type?(t) }
195
+
196
+ has_nil && !struct_type.nil?
197
+ end
198
+
173
199
  # Checks if a type is a scalar (primitives that don't need special serialization)
174
200
  sig { params(type_object: T.untyped).returns(T::Boolean) }
175
201
  def scalar_type?(type_object)
@@ -283,9 +309,11 @@ module DSPy
283
309
  # Coerces a hash value, converting keys and values as needed
284
310
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
285
311
  def coerce_hash_value(value, prop_type)
286
- return value unless value.is_a?(Hash)
287
312
  return value unless prop_type.is_a?(T::Types::TypedHash)
288
-
313
+
314
+ value = try_parse_string_to_hash(value)
315
+ return value unless value.is_a?(Hash)
316
+
289
317
  key_type = prop_type.keys
290
318
  value_type = prop_type.values
291
319
 
@@ -302,9 +330,41 @@ module DSPy
302
330
  result.transform_values { |v| coerce_value_to_type(v, value_type) }
303
331
  end
304
332
 
333
+ # Attempts to parse a string into a Hash.
334
+ # Returns the parsed Hash on success, or the original value otherwise.
335
+ sig { params(value: T.untyped).returns(T.untyped) }
336
+ def try_parse_string_to_hash(value)
337
+ return value unless value.is_a?(String)
338
+
339
+ parsed = begin
340
+ JSON.parse(value)
341
+ rescue JSON::ParserError
342
+ YAML.safe_load(value, permitted_classes: [Symbol, Date, Time])
343
+ end
344
+
345
+ parsed.is_a?(Hash) ? parsed : value
346
+ rescue Psych::SyntaxError
347
+ value
348
+ end
349
+
350
+ # Attempts to parse a JSON string into a Hash.
351
+ # Returns the parsed Hash on success, or the original value otherwise.
352
+ sig { params(value: T.untyped).returns(T.untyped) }
353
+ def try_parse_json_to_hash(value)
354
+ return value unless value.is_a?(String)
355
+
356
+ parsed = JSON.parse(value)
357
+ parsed.is_a?(Hash) ? parsed : value
358
+ rescue JSON::ParserError
359
+ value
360
+ end
361
+
305
362
  # Coerces a struct value from a hash
306
363
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
307
364
  def coerce_struct_value(value, prop_type)
365
+ # Anthropic tool use may return struct fields as JSON strings
366
+ value = try_parse_json_to_hash(value)
367
+
308
368
  return value unless value.is_a?(Hash)
309
369
 
310
370
  struct_class = if prop_type.is_a?(Class)
@@ -347,7 +407,7 @@ module DSPy
347
407
  next false unless prop_info
348
408
  prop_type = prop_info[:type_object] || prop_info[:type]
349
409
  has_default = prop_info.key?(:default) || prop_info[:fully_optional]
350
- !is_nilable_type?(prop_type) && has_default
410
+ !nilable_type?(prop_type) && has_default
351
411
  end
352
412
 
353
413
  # Create the struct instance
@@ -363,18 +423,20 @@ module DSPy
363
423
  def coerce_union_value(value, union_type)
364
424
  # Anthropic tool use may return complex oneOf union fields as JSON strings
365
425
  # instead of nested objects. Parse them back into Hashes for coercion.
366
- if value.is_a?(String)
367
- begin
368
- parsed = JSON.parse(value)
369
- value = parsed if parsed.is_a?(Hash)
370
- rescue JSON::ParserError
371
- # Not JSON — fall through
372
- end
373
- end
426
+ value = try_parse_json_to_hash(value)
374
427
 
375
428
  return value unless value.is_a?(Hash)
376
429
 
377
- # Check for _type discriminator field
430
+ # Handle nilable struct unions (T.nilable(SomeStruct)) without _type discriminator
431
+ # LLMs don't provide _type for simple nilable structs, so we can directly coerce
432
+ if nilable_struct_union?(union_type)
433
+ struct_type = union_type.types.find { |t|
434
+ t != T::Utils.coerce(NilClass) && struct_type?(t)
435
+ }
436
+ return coerce_struct_value(value, struct_type) if struct_type
437
+ end
438
+
439
+ # Check for _type discriminator field (required for true multi-type unions)
378
440
  type_name = value[:_type] || value["_type"]
379
441
  return value unless type_name
380
442