dspy 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85ae2299dbddc20bfd710c68e9e8dfbeeb201b742e3fd18b768669ddc4443261
4
- data.tar.gz: ba0ee2d5f637f499448e456fb58f0513aa58b5b258e754bf1b32839ea8c49b63
3
+ metadata.gz: abf60ee3b93bfb4b1a66af4bf59c2b6d4422bbe4f6b130f84fcafe03e9817cf3
4
+ data.tar.gz: 4e5678be47e6b76b974a7cc2dec2adf6a1f5609ac5ac6d07cdfdc3c67f804703
5
5
  SHA512:
6
- metadata.gz: 310de5ce11b23d29bd168069eae4f5ce3ac154ba91974c96b9d70e5d02a0dcb45122dc4c83f80097a409ff448a0a30d45ebec3ff0883d80b2cdd8f1215d2dfff
7
- data.tar.gz: 1ce5ff1bfe900bcedabab28efba1e8352fed81c6cbf458d5b7c54e8f7eea29fa6b7ee33a62dcc73b456ede6181a85bbf055541e4fb70037ac4de05e3be2b8ce9
6
+ metadata.gz: 91036445e7e1c45c56275722a1c96beb8b716adb2d4036fe6df051a4d554eb47c39dbbc2e6c0994135df3d0000dfca25572b21975d1ac02fa2ff49ce09c183c4
7
+ data.tar.gz: 302be904ad43e6a64405521d6bf9f02c2539debd252933cf9d9bcebb0fa7c81abb7fa0b26b5abdb1a3502a55abed8f8c3dabb76278ff27813f196e9ae7938cd3
data/README.md CHANGED
@@ -6,96 +6,16 @@
6
6
  [![Documentation](https://img.shields.io/badge/docs-oss.vicente.services%2Fdspy.rb-blue)](https://oss.vicente.services/dspy.rb/)
7
7
  [![Discord](https://img.shields.io/discord/1161519468141355160?label=discord&logo=discord&logoColor=white)](https://discord.gg/zWBhrMqn)
8
8
 
9
- **Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
9
+ **Build typed agents and model-backed programs in Ruby.**
10
10
 
11
- DSPy.rb is the Ruby port of Stanford's [DSPy](https://dspy.ai). Instead of wrestling with brittle prompt strings, you define typed signatures and let the framework handle the rest. Prompts become functions. LLM calls become predictable.
12
- The `1.x` line is the stable release track for production Ruby LLM applications.
11
+ DSPy.rb brings [DSPy](https://dspy.ai)'s signature, module, agent, and optimizer model to Ruby, with Sorbet types and Ruby-native integrations. A signature declares a task as typed inputs and outputs. A module chooses how to execute it. Ruby composes modules into programs, while `ReAct` adds a bounded loop when the model should choose the next action. DSPy.rb builds the provider request and validates the returned shape.
13
12
 
14
- ```ruby
15
- require 'dspy'
16
-
17
- DSPy.configure do |c|
18
- c.lm = DSPy::LM.new('openai/gpt-4o-mini', api_key: ENV['OPENAI_API_KEY'])
19
- end
20
-
21
- class Summarize < DSPy::Signature
22
- description "Summarize the given text in one sentence."
23
-
24
- input do
25
- const :text, String
26
- end
27
-
28
- output do
29
- const :summary, String
30
- end
31
- end
32
-
33
- summarizer = DSPy::Predict.new(Summarize)
34
- result = summarizer.call(text: "DSPy.rb brings structured LLM programming to Ruby...")
35
- puts result.summary
36
- ```
37
-
38
- That's it. No prompt templates. No JSON parsing. No prayer-based error handling.
39
-
40
- ## Installation
41
-
42
- ```ruby
43
- # Gemfile
44
- gem 'dspy'
45
- gem 'dspy-openai' # For OpenAI, OpenRouter, or Ollama
46
- # gem 'dspy-anthropic' # For Claude
47
- # gem 'dspy-gemini' # For Gemini
48
- # gem 'dspy-ruby_llm' # For 12+ providers via RubyLLM
49
- ```
50
-
51
- ```bash
52
- bundle install
53
- ```
54
-
55
- ## Quick Start
56
-
57
- ### Configure Your LLM
58
-
59
- ```ruby
60
- # OpenAI
61
- DSPy.configure do |c|
62
- c.lm = DSPy::LM.new('openai/gpt-4o-mini',
63
- api_key: ENV['OPENAI_API_KEY'],
64
- structured_outputs: true)
65
- end
66
-
67
- # Anthropic Claude
68
- DSPy.configure do |c|
69
- c.lm = DSPy::LM.new('anthropic/claude-sonnet-4-20250514',
70
- api_key: ENV['ANTHROPIC_API_KEY'])
71
- end
72
-
73
- # Google Gemini
74
- DSPy.configure do |c|
75
- c.lm = DSPy::LM.new('gemini/gemini-2.5-flash',
76
- api_key: ENV['GEMINI_API_KEY'])
77
- end
78
-
79
- # Ollama (local, free)
80
- DSPy.configure do |c|
81
- c.lm = DSPy::LM.new('ollama/llama3.2')
82
- end
83
-
84
- # OpenRouter (200+ models)
85
- DSPy.configure do |c|
86
- c.lm = DSPy::LM.new('openrouter/deepseek/deepseek-chat-v3.1:free',
87
- api_key: ENV['OPENROUTER_API_KEY'])
88
- end
89
- ```
90
-
91
- ### Define a Signature
13
+ The `1.x` series is the current stable release line.
92
14
 
93
- Signatures are typed contracts for LLM operations. Define inputs, outputs, and let DSPy handle the prompt:
15
+ In a configured application, the task contract and call look like this:
94
16
 
95
17
  ```ruby
96
18
  class Classify < DSPy::Signature
97
- description "Classify sentiment of a given sentence."
98
-
99
19
  class Sentiment < T::Enum
100
20
  enums do
101
21
  Positive = new('positive')
@@ -105,7 +25,7 @@ class Classify < DSPy::Signature
105
25
  end
106
26
 
107
27
  input do
108
- const :sentence, String, description: 'The sentence to analyze'
28
+ const :sentence, String
109
29
  end
110
30
 
111
31
  output do
@@ -115,107 +35,39 @@ class Classify < DSPy::Signature
115
35
  end
116
36
 
117
37
  classifier = DSPy::Predict.new(Classify)
118
- result = classifier.call(sentence: "This book was super fun to read!")
119
-
120
- result.sentiment # => #<Sentiment::Positive>
121
- result.confidence # => 0.92
38
+ result = classifier.call(sentence: "This book was fun to read!")
122
39
  ```
123
40
 
124
- ### Chain of Thought
125
-
126
- For complex reasoning, use `ChainOfThought` to get step-by-step explanations:
127
-
128
- ```ruby
129
- solver = DSPy::ChainOfThought.new(MathProblem)
130
- result = solver.call(problem: "If a train travels 120km in 2 hours, what's its speed?")
131
-
132
- result.reasoning # => "Speed = Distance / Time = 120km / 2h = 60km/h"
133
- result.answer # => "60 km/h"
134
- ```
41
+ Define a typed task contract instead of maintaining an output template. Receive validated Ruby values instead of parsing provider JSON. Handle configuration, transport, and validation errors explicitly.
135
42
 
136
- ### ReAct Agents
137
-
138
- Build agents that use tools to accomplish tasks:
139
-
140
- ```ruby
141
- class SearchTool < DSPy::Tools::Base
142
- tool_name "search"
143
- tool_description "Search for information"
144
-
145
- sig { params(query: String).returns(String) }
146
- def call(query:)
147
- # Your search implementation
148
- "Result 1, Result 2"
149
- end
150
- end
151
-
152
- agent = DSPy::ReAct.new(ResearchTask, tools: [SearchTool.new], max_iterations: 5)
153
- result = agent.call(question: "What's the latest on Ruby 3.4?")
154
- ```
43
+ ## Start Here
155
44
 
156
- ## What's Included
45
+ The [Quick Start](https://oss.vicente.services/dspy.rb/getting-started/quick-start/) is the complete supported path: install the core and provider gems, configure a key, save a program, and run it.
157
46
 
158
- **Core Modules**: Predict, ChainOfThought, ReAct agents, and composable pipelines.
47
+ Use [Installation](https://oss.vicente.services/dspy.rb/getting-started/installation/) to choose a provider. The [package and capability matrix](https://oss.vicente.services/dspy.rb/getting-started/packages/) records exact gem names, require behavior, support labels, and model or SDK boundaries.
159
48
 
160
- **Type Safety**: Sorbet-based runtime validation. Enums, unions, nested structs—all work.
49
+ ## Mental Model
161
50
 
162
- **Multimodal**: Image analysis with `DSPy::Image` for vision-capable models.
51
+ - A `DSPy::Signature` defines the task contract.
52
+ - `DSPy::Predict` and other modules choose an execution strategy.
53
+ - Ordinary Ruby owns fixed sequencing, branching, persistence, permissions, and failure policy.
54
+ - Results cross a runtime validation boundary; validation establishes shape, not factual correctness.
163
55
 
164
- **Observability**: Zero-config Langfuse integration via OpenTelemetry. Non-blocking, production-ready.
165
-
166
- **Optimization**: MIPROv2 (Bayesian optimization) and GEPA (genetic evolution) for prompt tuning.
167
-
168
- **Provider Support**: OpenAI, Anthropic, Gemini, Ollama, and OpenRouter via official SDKs.
169
-
170
- ## Documentation
171
-
172
- **[Full Documentation](https://oss.vicente.services/dspy.rb/)** — Getting started, core concepts, advanced patterns.
173
-
174
- **[llms.txt](https://oss.vicente.services/dspy.rb/llms.txt)** — LLM-friendly reference for AI assistants.
175
-
176
- ### Claude Skill
177
-
178
- A [Claude Skill](https://github.com/vicentereig/dspy-rb-skill) is available to help you build DSPy.rb applications:
179
-
180
- ```bash
181
- # Claude Code — install from the vicentereig/engineering marketplace
182
- claude install-skill vicentereig/engineering --skill dspy-rb
183
- ```
184
-
185
- 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.
186
-
187
- ## Examples
188
-
189
- The [examples/](examples/) directory has runnable code for common patterns:
190
-
191
- - Sentiment classification
192
- - ReAct agents with tools
193
- - Image analysis
194
- - Prompt optimization
195
-
196
- ```bash
197
- bundle exec ruby examples/basic_search_agent.rb
198
- ```
56
+ ### ReAct Agents
199
57
 
200
- ## Optional Gems
58
+ Use `ReAct` when the model needs to choose among typed Ruby tools inside an iteration bound. The application still owns tool authorization, side effects, budgets, and errors. See [Predictors](https://oss.vicente.services/dspy.rb/core-concepts/predictors/) and [Toolsets](https://oss.vicente.services/dspy.rb/core-concepts/toolsets/).
201
59
 
202
- DSPy.rb ships sibling gems for features with heavier dependencies. Add them as needed:
60
+ ## Explore
203
61
 
204
- | Gem | What it does |
205
- | --- | --- |
206
- | `dspy-datasets` | Dataset helpers, Parquet/Polars tooling |
207
- | `dspy-evals` | Evaluation harness with metrics and callbacks |
208
- | `dspy-miprov2` | Bayesian optimization for prompt tuning |
209
- | `dspy-gepa` | Genetic-Pareto prompt evolution |
210
- | `dspy-o11y-langfuse` | Auto-configure Langfuse tracing |
211
- | `dspy-code_act` | Think-Code-Observe agents |
212
- | `dspy-deep_search` | Production DeepSearch with Exa |
62
+ - [Documentation](https://oss.vicente.services/dspy.rb/) task-oriented guides and reference
63
+ - [Examples](examples/) repository demos indexed by capability and prerequisites
64
+ - [llms.txt](https://oss.vicente.services/dspy.rb/llms.txt) generated reference for AI assistants
213
65
 
214
- See [the full list](https://oss.vicente.services/dspy.rb/getting-started/installation/) in the docs.
66
+ Provider adapters, optimizers, observability exporters, datasets, and code-executing agents are separate packages when they add dependencies. Check the package matrix before selecting one; package availability does not guarantee uniform provider or model behavior.
215
67
 
216
68
  ## Contributing
217
69
 
218
- Feedback is invaluable. If you encounter issues, [open an issue](https://github.com/vicentereig/dspy.rb/issues). For suggestions, [start a discussion](https://github.com/vicentereig/dspy.rb/discussions).
70
+ For bugs, [open an issue](https://github.com/vicentereig/dspy.rb/issues). For suggestions, [start a discussion](https://github.com/vicentereig/dspy.rb/discussions).
219
71
 
220
72
  Want to contribute code? Reach out: hey at vicente.services
221
73
 
@@ -16,6 +16,13 @@ module DSPy
16
16
 
17
17
  PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai anthropic ollama gemini openrouter ruby_llm].freeze
18
18
 
19
+ # `reasoning:` is currently implemented only for the Anthropic adapter
20
+ # (see adr/019-anthropic-reasoning-temperature-config.md). Checked here,
21
+ # once, so every provider fails the same way instead of each adapter
22
+ # either raising a raw ArgumentError (no `reasoning:` keyword declared)
23
+ # or silently ignoring it (RubyLLMAdapter's `**options`).
24
+ REASONING_SUPPORTED_PROVIDERS = %w[anthropic].freeze
25
+
19
26
  class AdapterData < Data.define(:class_name, :gem_name)
20
27
  def self.from_prefix(provider_prefix)
21
28
  if ADAPTER_MAP.key?(provider_prefix)
@@ -37,7 +44,9 @@ module DSPy
37
44
  def create(model_id, api_key:, **options)
38
45
  provider, model = parse_model_id(model_id)
39
46
  adapter_class = get_adapter_class(provider)
40
-
47
+ ensure_reasoning_supported!(provider, options)
48
+ options = normalize_reasoning_option(provider, options)
49
+
41
50
  # Pass provider-specific options
42
51
  adapter_options = { model: model, api_key: api_key }
43
52
  # Some providers accept additional options
@@ -48,6 +57,27 @@ module DSPy
48
57
 
49
58
  private
50
59
 
60
+ def ensure_reasoning_supported!(provider, options)
61
+ return unless options.key?(:reasoning) && !options[:reasoning].nil?
62
+ return if REASONING_SUPPORTED_PROVIDERS.include?(provider)
63
+
64
+ raise DSPy::LM::ConfigurationError,
65
+ "reasoning: is currently supported only by the Anthropic adapter " \
66
+ "(got provider: #{provider.inspect}). See adr/019-anthropic-reasoning-temperature-config.md."
67
+ end
68
+
69
+ # `reasoning: nil` on an unsupported provider should behave exactly like
70
+ # not passing `reasoning:` at all — ensure_reasoning_supported! already
71
+ # let it through (only a *truthy* reasoning: raises above), but the key
72
+ # would otherwise still get forwarded into `**options`, and adapters
73
+ # that don't declare a `reasoning:` keyword (OpenAI, Gemini, Ollama,
74
+ # OpenRouter) would raise a raw ArgumentError over a value nobody set.
75
+ def normalize_reasoning_option(provider, options)
76
+ return options if REASONING_SUPPORTED_PROVIDERS.include?(provider)
77
+
78
+ options.reject { |key, _| key == :reasoning }
79
+ end
80
+
51
81
  # Parse model_id to determine provider and model
52
82
  def parse_model_id(model_id)
53
83
  unless model_id.include?('/')
@@ -78,11 +108,11 @@ module DSPy
78
108
 
79
109
  def ensure_adapter_loaded!(provider)
80
110
  adapter_data = adapter_data(provider)
81
- require adapter_data.require_path
82
111
  msg = <<~ERROR
83
112
  Adapter not found: #{adapter_data.class_name}.
84
113
  Install the #{adapter_data.gem_name} gem and try again.
85
114
  ERROR
115
+ require adapter_data.require_path
86
116
  rescue LoadError
87
117
  raise MissingAdapterError, msg
88
118
  end
@@ -38,7 +38,7 @@ 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: Beta API returns JSON in content, same as OpenAI/Gemini
41
+ # Anthropic: structured output returns JSON in content, same as OpenAI/Gemini
42
42
  extract_json_from_content(response.content)
43
43
  elsif adapter_class_name.include?('GeminiAdapter')
44
44
  # Gemini: try to extract JSON from various formats
@@ -97,14 +97,17 @@ module DSPy
97
97
 
98
98
  return unless structured_outputs_enabled
99
99
 
100
- # Use Anthropic Beta API structured outputs
100
+ # Structured outputs live under the stable (non-beta) `output_config.format`
101
+ # shape. `output_format`/`betas` are deprecated by Anthropic in favor of
102
+ # this (see adr/019-anthropic-reasoning-temperature-config.md); the adapter
103
+ # merges this with any `reasoning:`-derived effort into a single
104
+ # `output_config:` hash before calling the Anthropic client.
101
105
  schema = DSPy::Anthropic::LM::SchemaConverter.to_beta_format(signature_class)
102
106
 
103
- request_params[:output_format] = ::Anthropic::Models::Beta::BetaJSONOutputFormat.new(
107
+ request_params[:output_format] = ::Anthropic::Models::JSONOutputFormat.new(
104
108
  type: :json_schema,
105
109
  schema: schema
106
110
  )
107
- request_params[:betas] = ["structured-outputs-2025-11-13"]
108
111
  end
109
112
 
110
113
  # Gemini preparation
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Shared, provider-agnostic reasoning/thinking configuration.
7
+ #
8
+ # Passed to +DSPy::LM.new(..., reasoning: ...)+. Each adapter is responsible
9
+ # for mapping the resulting struct onto its own provider's request shape and
10
+ # raising +DSPy::LM::ConfigurationError+ for combinations it can't support
11
+ # (see the Anthropic adapter's +ModelCapabilities+ registry).
12
+ #
13
+ # Exactly one reasoning mode is set per instance (effort tier, manual token
14
+ # budget, adaptive thinking, or explicitly disabled) — this is a
15
+ # discriminated union, not a combinable set of options.
16
+ class Reasoning < T::Struct
17
+ extend T::Sig
18
+
19
+ # Named effort tiers, as documented by Anthropic's `output_config.effort`
20
+ # (https://platform.claude.com/docs/en/build-with-claude/effort).
21
+ class Effort < T::Enum
22
+ enums do
23
+ Low = new('low')
24
+ Medium = new('medium')
25
+ High = new('high')
26
+ XHigh = new('xhigh')
27
+ Max = new('max')
28
+ end
29
+ end
30
+
31
+ const :effort, T.nilable(Effort), default: nil
32
+ const :budget_tokens, T.nilable(Integer), default: nil
33
+ const :adaptive, T::Boolean, default: false
34
+ const :disabled, T::Boolean, default: false
35
+
36
+ sig { params(args: T.untyped).void }
37
+ def initialize(**args)
38
+ super
39
+
40
+ active = {
41
+ effort: !effort.nil?,
42
+ budget_tokens: !budget_tokens.nil?,
43
+ adaptive: adaptive,
44
+ disabled: disabled
45
+ }.select { |_, set| set }.keys
46
+
47
+ if active.size > 1
48
+ raise DSPy::LM::ConfigurationError,
49
+ "DSPy::Reasoning represents exactly one reasoning mode at a time " \
50
+ "(effort, budget_tokens, adaptive, or disabled), but #{active.size} were set: #{active.join(', ')}."
51
+ end
52
+ end
53
+
54
+ class << self
55
+ extend T::Sig
56
+
57
+ sig { returns(Reasoning) }
58
+ def low
59
+ new(effort: Effort::Low)
60
+ end
61
+
62
+ sig { returns(Reasoning) }
63
+ def medium
64
+ new(effort: Effort::Medium)
65
+ end
66
+
67
+ sig { returns(Reasoning) }
68
+ def high
69
+ new(effort: Effort::High)
70
+ end
71
+
72
+ sig { returns(Reasoning) }
73
+ def xhigh
74
+ new(effort: Effort::XHigh)
75
+ end
76
+
77
+ sig { returns(Reasoning) }
78
+ def max
79
+ new(effort: Effort::Max)
80
+ end
81
+
82
+ sig { params(tokens: Integer).returns(Reasoning) }
83
+ def budget(tokens)
84
+ new(budget_tokens: tokens)
85
+ end
86
+
87
+ sig { returns(Reasoning) }
88
+ def adaptive
89
+ new(adaptive: true)
90
+ end
91
+
92
+ sig { returns(Reasoning) }
93
+ def disabled
94
+ new(disabled: true)
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/dspy/lm.rb CHANGED
@@ -6,6 +6,7 @@ require 'securerandom'
6
6
 
7
7
  # Load adapter infrastructure
8
8
  require_relative 'lm/errors'
9
+ require_relative 'lm/reasoning'
9
10
  require_relative 'lm/response'
10
11
  require_relative 'lm/adapter'
11
12
  require_relative 'lm/adapter_factory'
@@ -299,9 +299,11 @@ module DSPy
299
299
  # Coerces an array value, converting each element as needed
300
300
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
301
301
  def coerce_array_value(value, prop_type)
302
- return value unless value.is_a?(Array)
303
302
  return value unless prop_type.is_a?(T::Types::TypedArray)
304
303
 
304
+ value = try_parse_string_to_array(value)
305
+ return value unless value.is_a?(Array)
306
+
305
307
  element_type = prop_type.type
306
308
  value.map { |element| coerce_value_to_type(element, element_type) }
307
309
  end
@@ -347,6 +349,18 @@ module DSPy
347
349
  value
348
350
  end
349
351
 
352
+ # Attempts to parse a JSON string into an Array.
353
+ # Returns the parsed Array on success, or the original value otherwise.
354
+ sig { params(value: T.untyped).returns(T.untyped) }
355
+ def try_parse_string_to_array(value)
356
+ return value unless value.is_a?(String)
357
+
358
+ parsed = JSON.parse(value)
359
+ parsed.is_a?(Array) ? parsed : value
360
+ rescue JSON::ParserError
361
+ value
362
+ end
363
+
350
364
  # Attempts to parse a JSON string into a Hash.
351
365
  # Returns the parsed Hash on success, or the original value otherwise.
352
366
  sig { params(value: T.untyped).returns(T.untyped) }
data/lib/dspy/predict.rb CHANGED
@@ -246,6 +246,11 @@ module DSPy
246
246
  sig { params(input_values: T::Hash[Symbol, T.untyped], output_attributes: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
247
247
  def create_prediction_result(input_values, output_attributes)
248
248
  begin
249
+ @signature_class.validate_required_fields!(
250
+ output_attributes,
251
+ @signature_class.output_field_descriptors
252
+ )
253
+
249
254
  combined_struct = create_combined_struct_class
250
255
  all_attributes = input_values.merge(output_attributes)
251
256
 
data/lib/dspy/re_act.rb CHANGED
@@ -110,6 +110,8 @@ module DSPy
110
110
  end
111
111
 
112
112
  FINISH_ACTION = "finish"
113
+ THOUGHT_LOOP_INSTRUCTION = "Generate a thought about what to do next to process the given inputs."
114
+ OBSERVATION_LOOP_INSTRUCTION = "Process the observation from a tool and decide what to do next."
113
115
  sig { returns(T.class_of(DSPy::Signature)) }
114
116
  attr_reader :original_signature_class
115
117
 
@@ -147,6 +149,15 @@ module DSPy
147
149
  # Create enhanced output struct with ReAct fields
148
150
  @enhanced_output_struct = create_enhanced_output_struct(signature_class)
149
151
  enhanced_output_struct = @enhanced_output_struct
152
+ input_descriptors = signature_class.input_field_descriptors
153
+ output_descriptors = input_descriptors.merge(signature_class.output_field_descriptors).merge(
154
+ history: DSPy::Signature::FieldDescriptor.new(
155
+ T::Array[T::Hash[Symbol, T.untyped]],
156
+ "ReAct execution history"
157
+ ),
158
+ iterations: DSPy::Signature::FieldDescriptor.new(Integer, "Number of iterations executed"),
159
+ tools_used: DSPy::Signature::FieldDescriptor.new(T::Array[String], "List of tools used during execution")
160
+ )
150
161
 
151
162
  # Create enhanced signature class
152
163
  enhanced_signature = Class.new(DSPy::Signature) do
@@ -155,9 +166,11 @@ module DSPy
155
166
 
156
167
  # Use the same input struct
157
168
  @input_struct_class = signature_class.input_struct_class
169
+ @input_field_descriptors = input_descriptors
158
170
 
159
171
  # Use the enhanced output struct with ReAct fields
160
172
  @output_struct_class = enhanced_output_struct
173
+ @output_field_descriptors = output_descriptors
161
174
 
162
175
  # Store original signature name
163
176
  @original_signature_name = signature_class.name
@@ -198,7 +211,9 @@ module DSPy
198
211
  def with_instruction(instruction)
199
212
  clone = self.class.new(@original_signature_class, tools: @tools.values, max_iterations: @max_iterations)
200
213
  thought_generator = clone.instance_variable_get(:@thought_generator)
214
+ observation_processor = clone.instance_variable_get(:@observation_processor)
201
215
  clone.instance_variable_set(:@thought_generator, thought_generator.with_instruction(instruction))
216
+ clone.instance_variable_set(:@observation_processor, observation_processor.with_instruction(instruction))
202
217
  clone
203
218
  end
204
219
 
@@ -224,6 +239,14 @@ module DSPy
224
239
 
225
240
  private
226
241
 
242
+ sig { params(signature_class: T.class_of(DSPy::Signature), loop_instruction: String).returns(String) }
243
+ def loop_description(signature_class, loop_instruction)
244
+ [
245
+ signature_class.description,
246
+ loop_instruction
247
+ ].compact.reject(&:empty?).join("\n\n")
248
+ end
249
+
227
250
  # Serialize value for LLM display
228
251
  sig { params(value: T.untyped).returns(T.untyped) }
229
252
  def serialize_for_llm(value)
@@ -302,6 +325,13 @@ module DSPy
302
325
  action_enum_class = @action_enum_class
303
326
  input_context_type = signature_class.input_struct_class || String
304
327
 
328
+ # Carry the user signature's instructions into the loop prompt, then append the
329
+ # loop mechanics — mirroring Python DSPy's ReAct, which prepends
330
+ # signature.instructions to the react predictor's instructions. Without this the
331
+ # user's task description never reaches the LM: it is only set on the enhanced
332
+ # signature used for typing, which issues no LM calls.
333
+ thought_description = loop_description(signature_class, THOUGHT_LOOP_INSTRUCTION)
334
+
305
335
  # Get the output field type for the final_answer field
306
336
  output_field_name = signature_class.output_struct_class.props.keys.first
307
337
  output_field_type = signature_class.output_struct_class.props[output_field_name][:type_object]
@@ -309,7 +339,7 @@ module DSPy
309
339
  # Create new class that inherits from DSPy::Signature
310
340
  Class.new(DSPy::Signature) do
311
341
  # Set description
312
- description "Generate a thought about what to do next to process the given inputs."
342
+ description thought_description
313
343
 
314
344
  # Define input fields
315
345
  input do
@@ -327,7 +357,7 @@ module DSPy
327
357
  description: "Reasoning about what to do next, considering the history and observations."
328
358
  const :action, action_enum_class,
329
359
  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."
330
- const :tool_input, ToolInput,
360
+ const :tool_input, ToolInput, default: nil,
331
361
  description: "Input for the chosen tool action. Required when action is a tool name. MUST be a JSON object matching the tool's parameter schema. Set to null when action is \"finish\"."
332
362
  const :final_answer, T.nilable(output_field_type),
333
363
  description: "The final answer to return. Required when action is \"finish\". Must match the expected output type. Set to null when action is a tool name."
@@ -339,10 +369,12 @@ module DSPy
339
369
  sig { params(signature_class: T.class_of(DSPy::Signature), data_format: Symbol).returns(T.class_of(DSPy::Signature)) }
340
370
  def create_observation_signature(signature_class, data_format)
341
371
  input_context_type = signature_class.input_struct_class || String
372
+ observation_description = loop_description(signature_class, OBSERVATION_LOOP_INSTRUCTION)
373
+
342
374
  # Create new class that inherits from DSPy::Signature
343
375
  Class.new(DSPy::Signature) do
344
376
  # Set description
345
- description "Process the observation from a tool and decide what to do next."
377
+ description observation_description
346
378
 
347
379
  # Define input fields
348
380
  input do
@@ -520,6 +552,11 @@ module DSPy
520
552
 
521
553
  output_data[output_field_name] = deserialized_value
522
554
 
555
+ @signature_class.validate_required_fields!(
556
+ output_data,
557
+ @signature_class.output_field_descriptors
558
+ )
559
+
523
560
  @enhanced_output_struct.new(**output_data)
524
561
  end
525
562
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module DSPy
4
4
  module RubyLLM
5
- VERSION = '0.1.1'
5
+ VERSION = '0.1.2'
6
6
  end
7
7
  end
@@ -15,7 +15,7 @@ module DSPy
15
15
  Categorical = new('CATEGORICAL')
16
16
  end
17
17
 
18
- sig { params(value: String).returns(DataType) }
18
+ sig { override.params(value: String).returns(DataType) }
19
19
  def self.deserialize(value)
20
20
  case value
21
21
  when 'NUMERIC' then Numeric
@@ -55,7 +55,7 @@ module DSPy
55
55
  sig { returns(T.class_of(T::Struct)) }
56
56
  def build_struct_class
57
57
  descriptors = @field_descriptors
58
- Class.new(T::Struct) do
58
+ struct_class = Class.new(T::Struct) do
59
59
  extend T::Sig
60
60
  descriptors.each do |name, descriptor|
61
61
  opts = {}
@@ -64,6 +64,22 @@ module DSPy
64
64
  const name, descriptor.type, **opts
65
65
  end
66
66
  end
67
+
68
+ struct_class.instance_variable_set(:@dspy_field_descriptors, descriptors)
69
+
70
+ # Use a virtual source path so source-comment introspection does not mistake
71
+ # this generated method for the anonymous struct's class definition.
72
+ struct_class.singleton_class.class_eval(<<~RUBY, "(dspy-signature-struct)", 1)
73
+ def new(*args, **kwargs)
74
+ properties = DSPy::Signature.constructor_properties(args, kwargs)
75
+ descriptors = instance_variable_get(:@dspy_field_descriptors)
76
+ DSPy::Signature.validate_required_fields!(properties, descriptors)
77
+
78
+ args.empty? ? super(**kwargs) : super(args.first)
79
+ end
80
+ RUBY
81
+
82
+ struct_class
67
83
  end
68
84
  end
69
85
 
@@ -85,6 +101,26 @@ module DSPy
85
101
  sig { returns(T::Hash[Symbol, FieldDescriptor]) }
86
102
  attr_reader :output_field_descriptors
87
103
 
104
+ sig { params(args: T.untyped, kwargs: T.untyped).returns(T::Hash[T.untyped, T.untyped]) }
105
+ def constructor_properties(args, kwargs)
106
+ return kwargs if args.empty?
107
+ return args.first if args.length == 1 && args.first.is_a?(Hash) && kwargs.empty?
108
+
109
+ raise ArgumentError, "Expected properties as keywords or one positional hash, not both"
110
+ end
111
+
112
+ sig { params(properties: T::Hash[T.untyped, T.untyped], descriptors: T::Hash[Symbol, FieldDescriptor]).void }
113
+ def validate_required_fields!(properties, descriptors)
114
+ required_fields = descriptors.reject { |_name, descriptor| descriptor.has_default }.keys
115
+ missing_fields = required_fields.reject do |name|
116
+ properties.key?(name) || properties.key?(name.to_s)
117
+ end
118
+ return if missing_fields.empty?
119
+
120
+ names = missing_fields.map(&:inspect).join(', ')
121
+ raise ArgumentError, "Missing required properties: #{names}"
122
+ end
123
+
88
124
  sig { params(desc: T.nilable(String)).returns(T.nilable(String)) }
89
125
  def description(desc = nil)
90
126
  if desc.nil?
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'json'
5
+ require_relative 'base'
5
6
  require_relative '../type_system/sorbet_json_schema'
6
7
  require_relative '../mixins/type_coercion'
7
8
 
@@ -193,4 +194,4 @@ module DSPy
193
194
  end
194
195
  end
195
196
  end
196
- end
197
+ end
data/lib/dspy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSPy
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
@@ -177,6 +177,7 @@ files:
177
177
  - lib/dspy/lm/json_strategy.rb
178
178
  - lib/dspy/lm/message.rb
179
179
  - lib/dspy/lm/message_builder.rb
180
+ - lib/dspy/lm/reasoning.rb
180
181
  - lib/dspy/lm/response.rb
181
182
  - lib/dspy/lm/usage.rb
182
183
  - lib/dspy/lm/vision_models.rb