dspy 0.27.0 → 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.
@@ -0,0 +1,437 @@
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 :create_issue, description: "Create a new GitHub issue"
65
+ tool :create_pr, description: "Create a new GitHub pull request"
66
+ tool :list_issues, description: "List GitHub issues with optional filters"
67
+ tool :list_prs, description: "List GitHub pull requests with optional filters"
68
+ tool :get_issue, description: "Get details of a specific GitHub issue"
69
+ tool :get_pr, description: "Get details of a specific GitHub pull request"
70
+ tool :comment_on_issue, description: "Add a comment to a GitHub issue"
71
+ tool :review_pr, description: "Add a review to a GitHub pull request"
72
+ tool :api_request, description: "Make an arbitrary GitHub API request"
73
+
74
+ sig { void }
75
+ def initialize
76
+ # No persistent state needed
77
+ end
78
+
79
+ sig { params(
80
+ title: String,
81
+ body: String,
82
+ labels: T::Array[String],
83
+ assignees: T::Array[String],
84
+ repo: T.nilable(String)
85
+ ).returns(String) }
86
+ def create_issue(title:, body:, labels: [], assignees: [], repo: nil)
87
+ cmd = build_gh_command(['issue', 'create'])
88
+ cmd << ['--title', shell_escape(title)]
89
+ cmd << ['--body', shell_escape(body)]
90
+
91
+ labels.each { |label| cmd << ['--label', shell_escape(label)] }
92
+ assignees.each { |assignee| cmd << ['--assignee', shell_escape(assignee)] }
93
+
94
+ if repo
95
+ cmd << ['--repo', shell_escape(repo)]
96
+ end
97
+
98
+ result = execute_command(cmd.flatten.join(' '))
99
+
100
+ if result[:success]
101
+ "Issue created successfully: #{result[:output].strip}"
102
+ else
103
+ "Failed to create issue: #{result[:error]}"
104
+ end
105
+ rescue => e
106
+ "Error creating issue: #{e.message}"
107
+ end
108
+
109
+ sig { params(
110
+ title: String,
111
+ body: String,
112
+ base: String,
113
+ head: String,
114
+ repo: T.nilable(String)
115
+ ).returns(String) }
116
+ def create_pr(title:, body:, base:, head:, repo: nil)
117
+ cmd = build_gh_command(['pr', 'create'])
118
+ cmd << ['--title', shell_escape(title)]
119
+ cmd << ['--body', shell_escape(body)]
120
+ cmd << ['--base', shell_escape(base)]
121
+ cmd << ['--head', shell_escape(head)]
122
+
123
+ if repo
124
+ cmd << ['--repo', shell_escape(repo)]
125
+ end
126
+
127
+ result = execute_command(cmd.flatten.join(' '))
128
+
129
+ if result[:success]
130
+ "Pull request created successfully: #{result[:output].strip}"
131
+ else
132
+ "Failed to create pull request: #{result[:error]}"
133
+ end
134
+ rescue => e
135
+ "Error creating pull request: #{e.message}"
136
+ end
137
+
138
+ sig { params(
139
+ state: IssueState,
140
+ labels: T::Array[String],
141
+ assignee: T.nilable(String),
142
+ repo: T.nilable(String),
143
+ limit: Integer
144
+ ).returns(String) }
145
+ def list_issues(state: IssueState::Open, labels: [], assignee: nil, repo: nil, limit: 20)
146
+ cmd = build_gh_command(['issue', 'list', '--json', 'number,title,state,labels,assignees,url'])
147
+ cmd << ['--state', state.serialize]
148
+ cmd << ['--limit', limit.to_s]
149
+
150
+ labels.each { |label| cmd << ['--label', shell_escape(label)] }
151
+
152
+ if assignee
153
+ cmd << ['--assignee', shell_escape(assignee)]
154
+ end
155
+
156
+ if repo
157
+ cmd << ['--repo', shell_escape(repo)]
158
+ end
159
+
160
+ result = execute_command(cmd.flatten.join(' '))
161
+
162
+ if result[:success]
163
+ parse_issue_list(result[:output])
164
+ else
165
+ "Failed to list issues: #{result[:error]}"
166
+ end
167
+ rescue => e
168
+ "Error listing issues: #{e.message}"
169
+ end
170
+
171
+ sig { params(
172
+ state: PRState,
173
+ author: T.nilable(String),
174
+ base: T.nilable(String),
175
+ repo: T.nilable(String),
176
+ limit: Integer
177
+ ).returns(String) }
178
+ def list_prs(state: PRState::Open, author: nil, base: nil, repo: nil, limit: 20)
179
+ cmd = build_gh_command(['pr', 'list', '--json', 'number,title,state,baseRefName,headRefName,url'])
180
+ cmd << ['--state', state.serialize]
181
+ cmd << ['--limit', limit.to_s]
182
+
183
+ if author
184
+ cmd << ['--author', shell_escape(author)]
185
+ end
186
+
187
+ if base
188
+ cmd << ['--base', shell_escape(base)]
189
+ end
190
+
191
+ if repo
192
+ cmd << ['--repo', shell_escape(repo)]
193
+ end
194
+
195
+ result = execute_command(cmd.flatten.join(' '))
196
+
197
+ if result[:success]
198
+ parse_pr_list(result[:output])
199
+ else
200
+ "Failed to list pull requests: #{result[:error]}"
201
+ end
202
+ rescue => e
203
+ "Error listing pull requests: #{e.message}"
204
+ end
205
+
206
+ sig { params(issue_number: Integer, repo: T.nilable(String)).returns(String) }
207
+ def get_issue(issue_number:, repo: nil)
208
+ cmd = build_gh_command(['issue', 'view', issue_number.to_s, '--json', 'number,title,state,body,labels,assignees,url'])
209
+
210
+ if repo
211
+ cmd << ['--repo', shell_escape(repo)]
212
+ end
213
+
214
+ result = execute_command(cmd.flatten.join(' '))
215
+
216
+ if result[:success]
217
+ parse_issue_details(result[:output])
218
+ else
219
+ "Failed to get issue: #{result[:error]}"
220
+ end
221
+ rescue => e
222
+ "Error getting issue: #{e.message}"
223
+ end
224
+
225
+ sig { params(pr_number: Integer, repo: T.nilable(String)).returns(String) }
226
+ def get_pr(pr_number:, repo: nil)
227
+ cmd = build_gh_command(['pr', 'view', pr_number.to_s, '--json', 'number,title,state,body,baseRefName,headRefName,mergeable,url'])
228
+
229
+ if repo
230
+ cmd << ['--repo', shell_escape(repo)]
231
+ end
232
+
233
+ result = execute_command(cmd.flatten.join(' '))
234
+
235
+ if result[:success]
236
+ parse_pr_details(result[:output])
237
+ else
238
+ "Failed to get pull request: #{result[:error]}"
239
+ end
240
+ rescue => e
241
+ "Error getting pull request: #{e.message}"
242
+ end
243
+
244
+ sig { params(
245
+ issue_number: Integer,
246
+ comment: String,
247
+ repo: T.nilable(String)
248
+ ).returns(String) }
249
+ def comment_on_issue(issue_number:, comment:, repo: nil)
250
+ cmd = build_gh_command(['issue', 'comment', issue_number.to_s])
251
+ cmd << ['--body', shell_escape(comment)]
252
+
253
+ if repo
254
+ cmd << ['--repo', shell_escape(repo)]
255
+ end
256
+
257
+ result = execute_command(cmd.flatten.join(' '))
258
+
259
+ if result[:success]
260
+ "Comment added successfully to issue ##{issue_number}"
261
+ else
262
+ "Failed to add comment: #{result[:error]}"
263
+ end
264
+ rescue => e
265
+ "Error adding comment: #{e.message}"
266
+ end
267
+
268
+ sig { params(
269
+ pr_number: Integer,
270
+ review_type: ReviewState,
271
+ comment: T.nilable(String),
272
+ repo: T.nilable(String)
273
+ ).returns(String) }
274
+ def review_pr(pr_number:, review_type:, comment: nil, repo: nil)
275
+ cmd = build_gh_command(['pr', 'review', pr_number.to_s])
276
+ cmd << ['--' + review_type.serialize.tr('_', '-')]
277
+
278
+ if comment
279
+ cmd << ['--body', shell_escape(comment)]
280
+ end
281
+
282
+ if repo
283
+ cmd << ['--repo', shell_escape(repo)]
284
+ end
285
+
286
+ result = execute_command(cmd.flatten.join(' '))
287
+
288
+ if result[:success]
289
+ "Review added successfully to PR ##{pr_number}"
290
+ else
291
+ "Failed to add review: #{result[:error]}"
292
+ end
293
+ rescue => e
294
+ "Error adding review: #{e.message}"
295
+ end
296
+
297
+ sig { params(
298
+ endpoint: String,
299
+ method: String,
300
+ fields: T::Hash[String, String],
301
+ repo: T.nilable(String)
302
+ ).returns(String) }
303
+ def api_request(endpoint:, method: 'GET', fields: {}, repo: nil)
304
+ cmd = build_gh_command(['api', endpoint])
305
+ cmd << ['--method', method.upcase]
306
+
307
+ fields.each do |key, value|
308
+ cmd << ['-f', "#{key}=#{shell_escape(value)}"]
309
+ end
310
+
311
+ if repo
312
+ cmd << ['--repo', shell_escape(repo)]
313
+ end
314
+
315
+ result = execute_command(cmd.flatten.join(' '))
316
+
317
+ if result[:success]
318
+ result[:output]
319
+ else
320
+ "API request failed: #{result[:error]}"
321
+ end
322
+ rescue => e
323
+ "Error making API request: #{e.message}"
324
+ end
325
+
326
+ private
327
+
328
+ sig { params(args: T::Array[String]).returns(T::Array[String]) }
329
+ def build_gh_command(args)
330
+ ['gh'] + args
331
+ end
332
+
333
+ sig { params(str: String).returns(String) }
334
+ def shell_escape(str)
335
+ "\"#{str.gsub(/"/, '\\"')}\""
336
+ end
337
+
338
+ sig { params(cmd: String).returns(T::Hash[Symbol, T.untyped]) }
339
+ def execute_command(cmd)
340
+ output = `#{cmd} 2>&1`
341
+ success = Process.last_status.success?
342
+
343
+ {
344
+ success: success,
345
+ output: success ? output : '',
346
+ error: success ? '' : output
347
+ }
348
+ end
349
+
350
+ sig { params(json_output: String).returns(String) }
351
+ def parse_issue_list(json_output)
352
+ issues = JSON.parse(json_output)
353
+
354
+ if issues.empty?
355
+ "No issues found"
356
+ else
357
+ result = ["Found #{issues.length} issue(s):"]
358
+ issues.each do |issue|
359
+ labels = issue['labels']&.map { |l| l['name'] } || []
360
+ assignees = issue['assignees']&.map { |a| a['login'] } || []
361
+
362
+ result << "##{issue['number']}: #{issue['title']} (#{issue['state']})"
363
+ result << " Labels: #{labels.join(', ')}" unless labels.empty?
364
+ result << " Assignees: #{assignees.join(', ')}" unless assignees.empty?
365
+ result << " URL: #{issue['url']}"
366
+ result << ""
367
+ end
368
+ result.join("\n")
369
+ end
370
+ rescue JSON::ParserError => e
371
+ "Failed to parse issues data: #{e.message}"
372
+ end
373
+
374
+ sig { params(json_output: String).returns(String) }
375
+ def parse_pr_list(json_output)
376
+ prs = JSON.parse(json_output)
377
+
378
+ if prs.empty?
379
+ "No pull requests found"
380
+ else
381
+ result = ["Found #{prs.length} pull request(s):"]
382
+ prs.each do |pr|
383
+ result << "##{pr['number']}: #{pr['title']} (#{pr['state']})"
384
+ result << " #{pr['headRefName']} → #{pr['baseRefName']}"
385
+ result << " URL: #{pr['url']}"
386
+ result << ""
387
+ end
388
+ result.join("\n")
389
+ end
390
+ rescue JSON::ParserError => e
391
+ "Failed to parse pull requests data: #{e.message}"
392
+ end
393
+
394
+ sig { params(json_output: String).returns(String) }
395
+ def parse_issue_details(json_output)
396
+ issue = JSON.parse(json_output)
397
+ labels = issue['labels']&.map { |l| l['name'] } || []
398
+ assignees = issue['assignees']&.map { |a| a['login'] } || []
399
+
400
+ result = []
401
+ result << "Issue ##{issue['number']}: #{issue['title']}"
402
+ result << "State: #{issue['state']}"
403
+ result << "Labels: #{labels.join(', ')}" unless labels.empty?
404
+ result << "Assignees: #{assignees.join(', ')}" unless assignees.empty?
405
+ result << "URL: #{issue['url']}"
406
+ result << ""
407
+ result << "Body:"
408
+ body = issue['body']
409
+ result << (body && !body.empty? ? body : "No description provided")
410
+
411
+ result.join("\n")
412
+ rescue JSON::ParserError => e
413
+ "Failed to parse issue details: #{e.message}"
414
+ end
415
+
416
+ sig { params(json_output: String).returns(String) }
417
+ def parse_pr_details(json_output)
418
+ pr = JSON.parse(json_output)
419
+
420
+ result = []
421
+ result << "Pull Request ##{pr['number']}: #{pr['title']}"
422
+ result << "State: #{pr['state']}"
423
+ result << "Branch: #{pr['headRefName']} → #{pr['baseRefName']}"
424
+ result << "Mergeable: #{pr['mergeable'] ? 'Yes' : 'No'}"
425
+ result << "URL: #{pr['url']}"
426
+ result << ""
427
+ result << "Body:"
428
+ body = pr['body']
429
+ result << (body && !body.empty? ? body : "No description provided")
430
+
431
+ result.join("\n")
432
+ rescue JSON::ParserError => e
433
+ "Failed to parse pull request details: #{e.message}"
434
+ end
435
+ end
436
+ end
437
+ 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