dspy 0.27.1 → 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.
@@ -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
@@ -9,6 +11,7 @@ module DSPy
9
11
  class Base
10
12
  extend T::Sig
11
13
  extend T::Helpers
14
+ include DSPy::Mixins::TypeCoercion
12
15
 
13
16
  class << self
14
17
  extend T::Sig
@@ -30,14 +33,14 @@ module DSPy
30
33
 
31
34
  # Get the JSON schema for the call method based on its Sorbet signature
32
35
  sig { returns(T::Hash[Symbol, T.untyped]) }
33
- def call_schema
36
+ def call_schema_object
34
37
  method_obj = instance_method(:call)
35
38
  sig_info = T::Utils.signature_for_method(method_obj)
36
39
 
37
40
  if sig_info.nil?
38
41
  # Fallback for methods without signatures
39
42
  return {
40
- type: :object,
43
+ type: "object",
41
44
  properties: {},
42
45
  required: []
43
46
  }
@@ -50,10 +53,8 @@ module DSPy
50
53
  sig_info.arg_types.each do |param_name, param_type|
51
54
  next if param_name == :block # Skip block parameters
52
55
 
53
- properties[param_name] = {
54
- type: sorbet_type_to_json_schema(param_type)[:type],
55
- description: "Parameter #{param_name}"
56
- }
56
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
57
+ properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
57
58
 
58
59
  # Check if parameter is required (not nilable)
59
60
  unless param_type.class.name.include?('Union') && param_type.name.include?('NilClass')
@@ -65,10 +66,8 @@ module DSPy
65
66
  sig_info.kwarg_types.each do |param_name, param_type|
66
67
  next if param_name == :block # Skip block parameters
67
68
 
68
- properties[param_name] = {
69
- type: sorbet_type_to_json_schema(param_type)[:type],
70
- description: "Parameter #{param_name}"
71
- }
69
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
70
+ properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
72
71
 
73
72
  # Check if parameter is required by looking at required kwarg names
74
73
  if sig_info.req_kwarg_names.include?(param_name)
@@ -79,54 +78,25 @@ module DSPy
79
78
  end
80
79
 
81
80
  {
82
- type: :object,
81
+ type: "object",
83
82
  properties: properties,
84
83
  required: required
85
84
  }
86
85
  end
87
86
 
88
- private
89
-
90
- # Convert Sorbet types to JSON Schema types
91
- sig { params(sorbet_type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
92
- def sorbet_type_to_json_schema(sorbet_type)
93
- if sorbet_type.is_a?(T::Types::Simple)
94
- raw_type = sorbet_type.raw_type
95
-
96
- if raw_type == String
97
- { type: :string }
98
- elsif raw_type == Integer
99
- { type: :integer }
100
- elsif raw_type == Float
101
- { type: :number }
102
- elsif raw_type == Numeric
103
- { type: :number }
104
- elsif raw_type == TrueClass || raw_type == FalseClass
105
- { type: :boolean }
106
- elsif raw_type == T::Boolean
107
- { type: :boolean }
108
- else
109
- { type: :string, description: "#{raw_type} (converted to string)" }
110
- end
111
- elsif sorbet_type.is_a?(T::Types::Union)
112
- # Handle nilable types
113
- non_nil_types = sorbet_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
114
- if non_nil_types.length == 1
115
- result = sorbet_type_to_json_schema(non_nil_types.first)
116
- result[:description] = "#{result[:description] || ''} (optional)".strip
117
- result
118
- else
119
- { type: :string, description: "Union type (converted to string)" }
120
- end
121
- elsif sorbet_type.is_a?(T::Types::TypedArray)
122
- {
123
- type: :array,
124
- items: sorbet_type_to_json_schema(sorbet_type.type)
87
+ # Get the full tool schema for LLM tools format
88
+ sig { returns(T::Hash[Symbol, T.untyped]) }
89
+ def call_schema
90
+ {
91
+ type: 'function',
92
+ function: {
93
+ name: 'call',
94
+ description: "Call the #{self.name} tool",
95
+ parameters: call_schema_object
125
96
  }
126
- else
127
- { type: :string, description: "#{sorbet_type} (converted to string)" }
128
- end
97
+ }
129
98
  end
99
+
130
100
  end
131
101
 
132
102
  # Instance methods that tools can use
@@ -143,7 +113,7 @@ module DSPy
143
113
  # Get the JSON schema string for the tool, formatted for LLM consumption
144
114
  sig { returns(String) }
145
115
  def schema
146
- schema_obj = self.class.call_schema
116
+ schema_obj = self.class.call_schema_object
147
117
  tool_info = {
148
118
  name: name,
149
119
  description: description,
@@ -152,11 +122,17 @@ module DSPy
152
122
  JSON.generate(tool_info)
153
123
  end
154
124
 
125
+ # Get the full call schema compatible with LLM tools format
126
+ sig { returns(T::Hash[Symbol, T.untyped]) }
127
+ def call_schema
128
+ self.class.call_schema
129
+ end
130
+
155
131
  # Dynamic call method for ReAct agent - parses JSON arguments and calls the typed method
156
132
  sig { params(args_json: T.untyped).returns(T.untyped) }
157
133
  def dynamic_call(args_json)
158
134
  # Parse arguments based on the call schema
159
- schema = self.class.call_schema
135
+ schema = self.class.call_schema_object
160
136
 
161
137
  if schema[:properties].empty?
162
138
  # No parameters - call without arguments
@@ -178,12 +154,34 @@ module DSPy
178
154
 
179
155
  # Convert string keys to symbols and validate types
180
156
  kwargs = {}
181
- schema[:properties].each do |param_name, param_schema|
182
- key = param_name.to_s
183
- if args.key?(key)
184
- kwargs[param_name] = convert_argument_type(args[key], param_schema)
185
- elsif schema[:required].include?(key)
186
- return "Error: Missing required parameter: #{key}"
157
+
158
+ # Get method signature for type information
159
+ method_obj = self.class.instance_method(:call)
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
187
185
  end
188
186
  end
189
187
 
@@ -195,32 +193,6 @@ module DSPy
195
193
 
196
194
  # Subclasses must implement their own call method with their own signature
197
195
 
198
- protected
199
-
200
- # Convert argument to the expected type based on JSON schema
201
- sig { params(value: T.untyped, schema: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
202
- def convert_argument_type(value, schema)
203
- case schema[:type]
204
- when :integer
205
- value.is_a?(Integer) ? value : value.to_i
206
- when :number
207
- # Always convert to Float for :number types to ensure compatibility with strict Float signatures
208
- value.to_f
209
- when :boolean
210
- case value
211
- when true, false
212
- value
213
- when "true", "1", 1
214
- true
215
- when "false", "0", 0
216
- false
217
- else
218
- !!value
219
- end
220
- else
221
- value.to_s
222
- end
223
- end
224
196
  end
225
197
  end
226
198
  end
@@ -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