dspy 0.27.1 → 0.27.3
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 +96 -3
- 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 +27 -37
- data/lib/dspy/re_act.rb +94 -35
- data/lib/dspy/signature.rb +12 -0
- data/lib/dspy/tools/base.rb +57 -85
- data/lib/dspy/tools/github_cli_toolset.rb +330 -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
@@ -0,0 +1,330 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sorbet-runtime'
|
4
|
+
require 'json'
|
5
|
+
require_relative 'toolset'
|
6
|
+
|
7
|
+
module DSPy
|
8
|
+
module Tools
|
9
|
+
# Enums for GitHub CLI operations
|
10
|
+
class IssueState < T::Enum
|
11
|
+
enums do
|
12
|
+
Open = new('open')
|
13
|
+
Closed = new('closed')
|
14
|
+
All = new('all')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class PRState < T::Enum
|
19
|
+
enums do
|
20
|
+
Open = new('open')
|
21
|
+
Closed = new('closed')
|
22
|
+
Merged = new('merged')
|
23
|
+
All = new('all')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class ReviewState < T::Enum
|
28
|
+
enums do
|
29
|
+
Approve = new('approve')
|
30
|
+
Comment = new('comment')
|
31
|
+
RequestChanges = new('request-changes')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Structs for complex return types
|
36
|
+
class IssueDetails < T::Struct
|
37
|
+
prop :number, Integer
|
38
|
+
prop :title, String
|
39
|
+
prop :state, String
|
40
|
+
prop :body, String
|
41
|
+
prop :url, String
|
42
|
+
prop :labels, T::Array[String]
|
43
|
+
prop :assignees, T::Array[String]
|
44
|
+
end
|
45
|
+
|
46
|
+
class PRDetails < T::Struct
|
47
|
+
prop :number, Integer
|
48
|
+
prop :title, String
|
49
|
+
prop :state, String
|
50
|
+
prop :body, String
|
51
|
+
prop :url, String
|
52
|
+
prop :base, String
|
53
|
+
prop :head, String
|
54
|
+
prop :mergeable, T::Boolean
|
55
|
+
end
|
56
|
+
|
57
|
+
# GitHub CLI toolset for common GitHub operations
|
58
|
+
class GitHubCLIToolset < Toolset
|
59
|
+
extend T::Sig
|
60
|
+
|
61
|
+
toolset_name "github"
|
62
|
+
|
63
|
+
# Expose methods as tools with descriptions
|
64
|
+
tool :list_issues, description: "List GitHub issues with optional filters"
|
65
|
+
tool :list_prs, description: "List GitHub pull requests with optional filters"
|
66
|
+
tool :get_issue, description: "Get details of a specific GitHub issue"
|
67
|
+
tool :get_pr, description: "Get details of a specific GitHub pull request"
|
68
|
+
tool :api_request, description: "Make an arbitrary GitHub API request"
|
69
|
+
|
70
|
+
sig { void }
|
71
|
+
def initialize
|
72
|
+
# No persistent state needed
|
73
|
+
end
|
74
|
+
|
75
|
+
|
76
|
+
|
77
|
+
sig { params(
|
78
|
+
state: IssueState,
|
79
|
+
labels: T::Array[String],
|
80
|
+
assignee: T.nilable(String),
|
81
|
+
repo: T.nilable(String),
|
82
|
+
limit: Integer
|
83
|
+
).returns(String) }
|
84
|
+
def list_issues(state: IssueState::Open, labels: [], assignee: nil, repo: nil, limit: 20)
|
85
|
+
cmd = build_gh_command(['issue', 'list', '--json', 'number,title,state,labels,assignees,url'])
|
86
|
+
cmd << ['--state', state.serialize]
|
87
|
+
cmd << ['--limit', limit.to_s]
|
88
|
+
|
89
|
+
labels.each { |label| cmd << ['--label', shell_escape(label)] }
|
90
|
+
|
91
|
+
if assignee
|
92
|
+
cmd << ['--assignee', shell_escape(assignee)]
|
93
|
+
end
|
94
|
+
|
95
|
+
if repo
|
96
|
+
cmd << ['--repo', shell_escape(repo)]
|
97
|
+
end
|
98
|
+
|
99
|
+
result = execute_command(cmd.flatten.join(' '))
|
100
|
+
|
101
|
+
if result[:success]
|
102
|
+
parse_issue_list(result[:output])
|
103
|
+
else
|
104
|
+
"Failed to list issues: #{result[:error]}"
|
105
|
+
end
|
106
|
+
rescue => e
|
107
|
+
"Error listing issues: #{e.message}"
|
108
|
+
end
|
109
|
+
|
110
|
+
sig { params(
|
111
|
+
state: PRState,
|
112
|
+
author: T.nilable(String),
|
113
|
+
base: T.nilable(String),
|
114
|
+
repo: T.nilable(String),
|
115
|
+
limit: Integer
|
116
|
+
).returns(String) }
|
117
|
+
def list_prs(state: PRState::Open, author: nil, base: nil, repo: nil, limit: 20)
|
118
|
+
cmd = build_gh_command(['pr', 'list', '--json', 'number,title,state,baseRefName,headRefName,url'])
|
119
|
+
cmd << ['--state', state.serialize]
|
120
|
+
cmd << ['--limit', limit.to_s]
|
121
|
+
|
122
|
+
if author
|
123
|
+
cmd << ['--author', shell_escape(author)]
|
124
|
+
end
|
125
|
+
|
126
|
+
if base
|
127
|
+
cmd << ['--base', shell_escape(base)]
|
128
|
+
end
|
129
|
+
|
130
|
+
if repo
|
131
|
+
cmd << ['--repo', shell_escape(repo)]
|
132
|
+
end
|
133
|
+
|
134
|
+
result = execute_command(cmd.flatten.join(' '))
|
135
|
+
|
136
|
+
if result[:success]
|
137
|
+
parse_pr_list(result[:output])
|
138
|
+
else
|
139
|
+
"Failed to list pull requests: #{result[:error]}"
|
140
|
+
end
|
141
|
+
rescue => e
|
142
|
+
"Error listing pull requests: #{e.message}"
|
143
|
+
end
|
144
|
+
|
145
|
+
sig { params(issue_number: Integer, repo: T.nilable(String)).returns(String) }
|
146
|
+
def get_issue(issue_number:, repo: nil)
|
147
|
+
cmd = build_gh_command(['issue', 'view', issue_number.to_s, '--json', 'number,title,state,body,labels,assignees,url'])
|
148
|
+
|
149
|
+
if repo
|
150
|
+
cmd << ['--repo', shell_escape(repo)]
|
151
|
+
end
|
152
|
+
|
153
|
+
result = execute_command(cmd.flatten.join(' '))
|
154
|
+
|
155
|
+
if result[:success]
|
156
|
+
parse_issue_details(result[:output])
|
157
|
+
else
|
158
|
+
"Failed to get issue: #{result[:error]}"
|
159
|
+
end
|
160
|
+
rescue => e
|
161
|
+
"Error getting issue: #{e.message}"
|
162
|
+
end
|
163
|
+
|
164
|
+
sig { params(pr_number: Integer, repo: T.nilable(String)).returns(String) }
|
165
|
+
def get_pr(pr_number:, repo: nil)
|
166
|
+
cmd = build_gh_command(['pr', 'view', pr_number.to_s, '--json', 'number,title,state,body,baseRefName,headRefName,mergeable,url'])
|
167
|
+
|
168
|
+
if repo
|
169
|
+
cmd << ['--repo', shell_escape(repo)]
|
170
|
+
end
|
171
|
+
|
172
|
+
result = execute_command(cmd.flatten.join(' '))
|
173
|
+
|
174
|
+
if result[:success]
|
175
|
+
parse_pr_details(result[:output])
|
176
|
+
else
|
177
|
+
"Failed to get pull request: #{result[:error]}"
|
178
|
+
end
|
179
|
+
rescue => e
|
180
|
+
"Error getting pull request: #{e.message}"
|
181
|
+
end
|
182
|
+
|
183
|
+
|
184
|
+
|
185
|
+
sig { params(
|
186
|
+
endpoint: String,
|
187
|
+
method: String,
|
188
|
+
fields: T::Hash[String, String],
|
189
|
+
repo: T.nilable(String)
|
190
|
+
).returns(String) }
|
191
|
+
def api_request(endpoint:, method: 'GET', fields: {}, repo: nil)
|
192
|
+
# Restrict to read-only operations
|
193
|
+
unless method.upcase == 'GET'
|
194
|
+
return "Error: Only GET requests are allowed for read-only access"
|
195
|
+
end
|
196
|
+
|
197
|
+
cmd = build_gh_command(['api', endpoint])
|
198
|
+
cmd << ['--method', method.upcase]
|
199
|
+
|
200
|
+
fields.each do |key, value|
|
201
|
+
cmd << ['-f', "#{key}=#{shell_escape(value)}"]
|
202
|
+
end
|
203
|
+
|
204
|
+
if repo
|
205
|
+
cmd << ['--repo', shell_escape(repo)]
|
206
|
+
end
|
207
|
+
|
208
|
+
result = execute_command(cmd.flatten.join(' '))
|
209
|
+
|
210
|
+
if result[:success]
|
211
|
+
result[:output]
|
212
|
+
else
|
213
|
+
"API request failed: #{result[:error]}"
|
214
|
+
end
|
215
|
+
rescue => e
|
216
|
+
"Error making API request: #{e.message}"
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
sig { params(args: T::Array[String]).returns(T::Array[String]) }
|
222
|
+
def build_gh_command(args)
|
223
|
+
['gh'] + args
|
224
|
+
end
|
225
|
+
|
226
|
+
sig { params(str: String).returns(String) }
|
227
|
+
def shell_escape(str)
|
228
|
+
"\"#{str.gsub(/"/, '\\"')}\""
|
229
|
+
end
|
230
|
+
|
231
|
+
sig { params(cmd: String).returns(T::Hash[Symbol, T.untyped]) }
|
232
|
+
def execute_command(cmd)
|
233
|
+
output = `#{cmd} 2>&1`
|
234
|
+
success = Process.last_status.success?
|
235
|
+
|
236
|
+
{
|
237
|
+
success: success,
|
238
|
+
output: success ? output : '',
|
239
|
+
error: success ? '' : output
|
240
|
+
}
|
241
|
+
end
|
242
|
+
|
243
|
+
sig { params(json_output: String).returns(String) }
|
244
|
+
def parse_issue_list(json_output)
|
245
|
+
issues = JSON.parse(json_output)
|
246
|
+
|
247
|
+
if issues.empty?
|
248
|
+
"No issues found"
|
249
|
+
else
|
250
|
+
result = ["Found #{issues.length} issue(s):"]
|
251
|
+
issues.each do |issue|
|
252
|
+
labels = issue['labels']&.map { |l| l['name'] } || []
|
253
|
+
assignees = issue['assignees']&.map { |a| a['login'] } || []
|
254
|
+
|
255
|
+
result << "##{issue['number']}: #{issue['title']} (#{issue['state']})"
|
256
|
+
result << " Labels: #{labels.join(', ')}" unless labels.empty?
|
257
|
+
result << " Assignees: #{assignees.join(', ')}" unless assignees.empty?
|
258
|
+
result << " URL: #{issue['url']}"
|
259
|
+
result << ""
|
260
|
+
end
|
261
|
+
result.join("\n")
|
262
|
+
end
|
263
|
+
rescue JSON::ParserError => e
|
264
|
+
"Failed to parse issues data: #{e.message}"
|
265
|
+
end
|
266
|
+
|
267
|
+
sig { params(json_output: String).returns(String) }
|
268
|
+
def parse_pr_list(json_output)
|
269
|
+
prs = JSON.parse(json_output)
|
270
|
+
|
271
|
+
if prs.empty?
|
272
|
+
"No pull requests found"
|
273
|
+
else
|
274
|
+
result = ["Found #{prs.length} pull request(s):"]
|
275
|
+
prs.each do |pr|
|
276
|
+
result << "##{pr['number']}: #{pr['title']} (#{pr['state']})"
|
277
|
+
result << " #{pr['headRefName']} → #{pr['baseRefName']}"
|
278
|
+
result << " URL: #{pr['url']}"
|
279
|
+
result << ""
|
280
|
+
end
|
281
|
+
result.join("\n")
|
282
|
+
end
|
283
|
+
rescue JSON::ParserError => e
|
284
|
+
"Failed to parse pull requests data: #{e.message}"
|
285
|
+
end
|
286
|
+
|
287
|
+
sig { params(json_output: String).returns(String) }
|
288
|
+
def parse_issue_details(json_output)
|
289
|
+
issue = JSON.parse(json_output)
|
290
|
+
labels = issue['labels']&.map { |l| l['name'] } || []
|
291
|
+
assignees = issue['assignees']&.map { |a| a['login'] } || []
|
292
|
+
|
293
|
+
result = []
|
294
|
+
result << "Issue ##{issue['number']}: #{issue['title']}"
|
295
|
+
result << "State: #{issue['state']}"
|
296
|
+
result << "Labels: #{labels.join(', ')}" unless labels.empty?
|
297
|
+
result << "Assignees: #{assignees.join(', ')}" unless assignees.empty?
|
298
|
+
result << "URL: #{issue['url']}"
|
299
|
+
result << ""
|
300
|
+
result << "Body:"
|
301
|
+
body = issue['body']
|
302
|
+
result << (body && !body.empty? ? body : "No description provided")
|
303
|
+
|
304
|
+
result.join("\n")
|
305
|
+
rescue JSON::ParserError => e
|
306
|
+
"Failed to parse issue details: #{e.message}"
|
307
|
+
end
|
308
|
+
|
309
|
+
sig { params(json_output: String).returns(String) }
|
310
|
+
def parse_pr_details(json_output)
|
311
|
+
pr = JSON.parse(json_output)
|
312
|
+
|
313
|
+
result = []
|
314
|
+
result << "Pull Request ##{pr['number']}: #{pr['title']}"
|
315
|
+
result << "State: #{pr['state']}"
|
316
|
+
result << "Branch: #{pr['headRefName']} → #{pr['baseRefName']}"
|
317
|
+
result << "Mergeable: #{pr['mergeable'] ? 'Yes' : 'No'}"
|
318
|
+
result << "URL: #{pr['url']}"
|
319
|
+
result << ""
|
320
|
+
result << "Body:"
|
321
|
+
body = pr['body']
|
322
|
+
result << (body && !body.empty? ? body : "No description provided")
|
323
|
+
|
324
|
+
result.join("\n")
|
325
|
+
rescue JSON::ParserError => e
|
326
|
+
"Failed to parse pull request details: #{e.message}"
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
data/lib/dspy/tools/toolset.rb
CHANGED
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
require 'sorbet-runtime'
|
4
4
|
require 'json'
|
5
|
+
require_relative '../type_system/sorbet_json_schema'
|
6
|
+
require_relative '../mixins/type_coercion'
|
5
7
|
|
6
8
|
module DSPy
|
7
9
|
module Tools
|
@@ -69,10 +71,8 @@ module DSPy
|
|
69
71
|
sig_info.kwarg_types.each do |param_name, param_type|
|
70
72
|
next if param_name == :block
|
71
73
|
|
72
|
-
|
73
|
-
|
74
|
-
description: "Parameter #{param_name}"
|
75
|
-
}
|
74
|
+
schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
|
75
|
+
properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
|
76
76
|
|
77
77
|
# Check if parameter is required
|
78
78
|
if sig_info.req_kwarg_names.include?(param_name)
|
@@ -89,61 +89,12 @@ module DSPy
|
|
89
89
|
}
|
90
90
|
end
|
91
91
|
|
92
|
-
private
|
93
|
-
|
94
|
-
# Convert Sorbet types to JSON Schema types (extracted from Base)
|
95
|
-
sig { params(sorbet_type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
|
96
|
-
def sorbet_type_to_json_schema(sorbet_type)
|
97
|
-
# Check for boolean types first (SimplePairUnion of TrueClass | FalseClass)
|
98
|
-
if sorbet_type.respond_to?(:types) && sorbet_type.types.length == 2
|
99
|
-
raw_types = sorbet_type.types.map do |t|
|
100
|
-
t.is_a?(T::Types::Simple) ? t.raw_type : t
|
101
|
-
end
|
102
|
-
|
103
|
-
if raw_types.include?(TrueClass) && raw_types.include?(FalseClass)
|
104
|
-
return { type: :boolean }
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
if sorbet_type.is_a?(T::Types::Simple)
|
109
|
-
raw_type = sorbet_type.raw_type
|
110
|
-
|
111
|
-
case raw_type
|
112
|
-
when String
|
113
|
-
{ type: :string }
|
114
|
-
when Integer
|
115
|
-
{ type: :integer }
|
116
|
-
when Float, Numeric
|
117
|
-
{ type: :number }
|
118
|
-
when TrueClass, FalseClass, T::Boolean
|
119
|
-
{ type: :boolean }
|
120
|
-
else
|
121
|
-
{ type: :string, description: "#{raw_type} (converted to string)" }
|
122
|
-
end
|
123
|
-
elsif sorbet_type.is_a?(T::Types::Union)
|
124
|
-
# Handle nilable types
|
125
|
-
non_nil_types = sorbet_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
|
126
|
-
if non_nil_types.length == 1
|
127
|
-
result = sorbet_type_to_json_schema(non_nil_types.first)
|
128
|
-
result[:description] = "#{result[:description] || ''} (optional)".strip
|
129
|
-
result
|
130
|
-
else
|
131
|
-
{ type: :string, description: "Union type (converted to string)" }
|
132
|
-
end
|
133
|
-
elsif sorbet_type.is_a?(T::Types::TypedArray)
|
134
|
-
{
|
135
|
-
type: :array,
|
136
|
-
items: sorbet_type_to_json_schema(sorbet_type.type)
|
137
|
-
}
|
138
|
-
else
|
139
|
-
{ type: :string, description: "#{sorbet_type} (converted to string)" }
|
140
|
-
end
|
141
|
-
end
|
142
92
|
end
|
143
93
|
|
144
94
|
# Inner class that wraps a method as a tool, compatible with DSPy::Tools::Base interface
|
145
95
|
class ToolProxy < Base
|
146
96
|
extend T::Sig
|
97
|
+
include DSPy::Mixins::TypeCoercion
|
147
98
|
|
148
99
|
sig { params(instance: Toolset, method_name: Symbol, tool_name: String, description: String).void }
|
149
100
|
def initialize(instance, method_name, tool_name, description)
|
@@ -203,12 +154,34 @@ module DSPy
|
|
203
154
|
|
204
155
|
# Convert string keys to symbols and validate types
|
205
156
|
kwargs = {}
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
157
|
+
|
158
|
+
# Get method signature for type information
|
159
|
+
method_obj = @instance.class.instance_method(@method_name)
|
160
|
+
sig_info = T::Utils.signature_for_method(method_obj)
|
161
|
+
|
162
|
+
if sig_info
|
163
|
+
# Handle kwargs using type signature information
|
164
|
+
sig_info.kwarg_types.each do |param_name, param_type|
|
165
|
+
next if param_name == :block
|
166
|
+
|
167
|
+
key = param_name.to_s
|
168
|
+
if args.key?(key)
|
169
|
+
kwargs[param_name] = coerce_value_to_type(args[key], param_type)
|
170
|
+
elsif schema[:required].include?(key)
|
171
|
+
return "Error: Missing required parameter: #{key}"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Handle positional args if any
|
176
|
+
sig_info.arg_types.each do |param_name, param_type|
|
177
|
+
next if param_name == :block
|
178
|
+
|
179
|
+
key = param_name.to_s
|
180
|
+
if args.key?(key)
|
181
|
+
kwargs[param_name] = coerce_value_to_type(args[key], param_type)
|
182
|
+
elsif schema[:required].include?(key)
|
183
|
+
return "Error: Missing required parameter: #{key}"
|
184
|
+
end
|
212
185
|
end
|
213
186
|
end
|
214
187
|
|