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.
@@ -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
@@ -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
- properties[param_name] = {
73
- type: sorbet_type_to_json_schema(param_type)[:type],
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
- schema[:properties].each do |param_name, param_schema|
207
- key = param_name.to_s
208
- if args.key?(key)
209
- kwargs[param_name] = convert_argument_type(args[key], param_schema)
210
- elsif schema[:required].include?(key)
211
- return "Error: Missing required parameter: #{key}"
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