ask-tools 0.1.0 → 0.1.1

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: 41146df4fa3ec2cec15d6085c09d14204cedba968ea1afab6afa72cadf2ec971
4
- data.tar.gz: 7592284b53e742c81654504d8e9c4b25e8f93c7876cd2a6b6fad2cc7dc8aa0c3
3
+ metadata.gz: 1a85f7d9d66c347416e813fa6f74b5f1d6f44f75107422d6d2de79f31318aa21
4
+ data.tar.gz: deab76f0fdde304174f3d6d11de03c556024b23e3163f0b4b51d2efbf74d834d
5
5
  SHA512:
6
- metadata.gz: 8de6e0c216ab3432b59c016e0edcee9a2de8e303e83b73655e49d7b75a71c08deff8dc22ea917d2ebd9c6f30f667dc6aede63c3fce61bcbce30a10cc4e09c127
7
- data.tar.gz: f7b0709e856f0f4e6eceaf21e7117bb59095fd6eebba267559fe0651468932b20e3d926feab4509145e4053ede86a41b70fcdc45deca176f2cb84dbfba9b0f84
6
+ metadata.gz: 20ea10459f3cef2feb1d59633d661a0fdd935c372d06d63967055a08723635999272cb5ff6f50115b06c797bb3a00e5fba61f5d580f365fe4de919d7a23aa9c2
7
+ data.tar.gz: f6e37198ee07a2bf183d6c46ee6c6d51fd638e48f71333c25cb3cb5870725da9ef36efc84439396f392132c3889e5c42835db771dfbafd5f220d3a95435ee0d5
@@ -1,30 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ask-schema"
4
+
3
5
  module Ask
4
- # Base class for defining tools that LLMs can call.
5
- #
6
- # Subclass +Ask::Tool+, use the DSL to declare metadata and parameters,
7
- # and implement +#execute+ to perform the work.
8
- #
9
- # class Greeter < Ask::Tool
10
- # description "Greets a person by name"
11
- # param :name, type: :string, desc: "The person's name", required: true
12
- #
13
- # def execute(name:)
14
- # Ask::Result.ok(data: "Hello, #{name}!")
15
- # end
16
- # end
17
- #
18
- # Greeter.new.name # => "greeter"
19
- # Greeter.new.call(name: "World")
20
- # # => #<Ask::Result ok=true output="Hello, World!">
21
- #
22
6
  class Tool
23
- # Raised (or returned from +#call+) to signal the conversation loop
24
- # should stop rather than continuing after this tool's result.
25
7
  class Halt < StandardError
26
8
  attr_reader :content
27
-
28
9
  def initialize(content)
29
10
  @content = content
30
11
  super(content.to_s)
@@ -32,81 +13,65 @@ module Ask
32
13
  end
33
14
 
34
15
  class << self
35
- # @api private
36
16
  def inherited(subclass)
37
17
  super
38
18
  @parameters = {} if @parameters.nil?
39
19
  subclass.instance_variable_set(:@description, nil)
40
20
  subclass.instance_variable_set(:@parameters, {})
21
+ subclass.instance_variable_set(:@params_schema_definition, nil)
41
22
  end
42
23
 
43
- # Set or retrieve the tool's human-readable description.
44
- #
45
- # @param text [String, nil] when provided, sets the description
46
- # @return [String, nil]
47
24
  def description(text = nil)
48
25
  return @description unless text
49
-
50
26
  @description = text
51
27
  end
52
28
  alias desc description
53
29
 
54
- # Declare a parameter the tool accepts.
55
- #
56
- # @param name [Symbol] parameter name
57
- # @param type [Symbol] JSON Schema type (+:string+, +:integer+, +:number+,
58
- # +:boolean+, +:array+, +:object+)
59
- # @param desc [String] human-readable description of the parameter
60
- # @param required [Boolean] whether the parameter is mandatory
61
- # @return [void]
62
30
  def param(name, type:, desc: nil, description: nil, required: true)
63
31
  type = type.to_s.downcase.to_sym
64
32
  validate_param_type!(type, name)
65
33
  parameters[name] = Parameter.new(
66
- name: name,
67
- type: map_type(type),
68
- description: desc || description,
69
- required: required
34
+ name: name, type: map_type(type),
35
+ description: desc || description, required: required
70
36
  )
71
37
  end
72
38
 
73
- # @api private
74
- # @return [Hash{Symbol => Ask::Tool::Parameter}]
75
- def parameters
76
- @parameters ||= {}
77
- end
78
-
79
- # @api private
80
- # Define tool parameters using the {Ask::Schema} DSL.
81
- #
82
- # When a block is provided, it takes precedence over individual
83
- # +param+ declarations for schema generation.
84
- #
85
- # @example
86
- # params do
87
- # string :location, description: "City name"
88
- # string :unit, enum: %w[celsius fahrenheit]
89
- # end
90
- #
91
- # @param schema [Ask::Schema, Class<Ask::Schema>, Hash, nil] A pre-built schema
92
- # @param block [Proc] DSL block evaluated by Ask::Schema
93
- # @return [void]
94
39
  def params(schema = nil, &block)
95
40
  @params_schema_definition = schema || block
96
41
  end
97
42
 
43
+ def parameters
44
+ @parameters ||= {}
45
+ end
46
+
98
47
  def provider_params
99
48
  @provider_params ||= {}
100
49
  end
50
+
51
+ private
52
+
53
+ def validate_param_type!(type, name)
54
+ return if VALID_JSON_SCHEMA_TYPES.include?(type)
55
+ raise ArgumentError,
56
+ "Invalid type #{type.inspect} for parameter #{name.inspect}. " \
57
+ "Valid types: #{VALID_JSON_SCHEMA_TYPES.map(&:inspect).join(', ')}"
58
+ end
59
+
60
+ def map_type(type)
61
+ case type
62
+ when :int then "integer"
63
+ when :float, :double then "number"
64
+ else type.to_s
65
+ end
66
+ end
67
+ end
68
+
69
+ def provider_params
70
+ self.class.provider_params
101
71
  end
102
72
 
103
- # Auto-derive the tool name from the class name.
104
- # Converts CamelCase to snake_case and strips a trailing +_tool+ suffix.
105
- #
106
- # @return [String]
107
73
  def name
108
- # Use only the class name (last segment), ignoring module nesting
109
- klass_name = self.class.name.to_s.split("::").last || self.class.name.to_s
74
+ klass_name = self.class.name.to_s || ""
110
75
  normalized = klass_name.dup.force_encoding("UTF-8").unicode_normalize(:nfkd)
111
76
  normalized.encode("ASCII", replace: "")
112
77
  .gsub(/[^a-zA-Z0-9_-]/, "-")
@@ -116,157 +81,113 @@ module Ask
116
81
  .delete_suffix("_tool")
117
82
  end
118
83
 
119
- # @return [String, nil] the tool's description
120
84
  def description
121
85
  self.class.description
122
86
  end
123
87
 
124
- # @return [Hash{Symbol => Ask::Tool::Parameter}]
125
88
  def parameters
126
89
  self.class.parameters
127
90
  end
128
91
 
129
- # Call the tool with the given arguments.
130
- #
131
- # Normalizes keys to symbols, validates required parameters,
132
- # and delegates to +#execute+.
133
- #
134
- # @param args [Hash, nil] keyword arguments for the tool
135
- # @return [Ask::Result] the tool's result
136
92
  def call(args = {})
137
93
  normalized = normalize_args(args)
138
94
  validation = validate(normalized)
139
- return Ask::Result.error(message: validation) if validation
140
-
95
+ return Ask::Result.failure(validation) if validation
141
96
  execute(**normalized)
142
97
  rescue Halt => e
143
98
  Ask::Result.ok(data: e.content, metadata: { halted: true })
144
99
  rescue StandardError => e
145
- Ask::Result.error(message: "#{self.class.name.split('::').last} raised #{e.class}: #{e.message}")
100
+ Ask::Result.failure("#{self.class.name.split('::').last} raised #{e.class}: #{e.message}")
146
101
  end
147
102
 
148
- # Subclasses must implement this method.
149
- #
150
- # @param args [Hash] normalized keyword arguments
151
- # @return [Ask::Result] the tool's result
152
103
  def execute(**)
153
104
  raise NotImplementedError, "#{self.class} must implement #execute(**args)"
154
105
  end
155
106
 
156
- # Generate a JSON Schema hash describing this tool's parameters.
157
- # Suitable for LLM function-calling APIs (OpenAI, Anthropic, etc.).
158
- #
159
- # @return [Hash]
160
107
  def params_schema
161
108
  return @params_schema if defined?(@params_schema)
162
-
163
109
  @params_schema = begin
164
- if parameters.empty?
165
- nil
110
+ if params_schema_definition
111
+ deep_stringify_keys(resolve_params_schema(params_schema_definition))
112
+ elsif parameters.any?
113
+ build_schema_from_params
166
114
  else
167
- properties = parameters.to_h do |_name, param|
168
- schema = { type: param.type }
169
- schema[:description] = param.description if param.description
170
- schema[:items] = { type: "string" } if param.type == "array"
171
- [param.name.to_s, schema]
172
- end
173
-
174
- required = parameters.select { |_, p| p.required }.keys.map(&:to_s)
175
-
176
- {
177
- type: "object",
178
- properties: properties,
179
- required: required,
180
- additionalProperties: false
181
- }
115
+ nil
182
116
  end
183
117
  end
184
118
  end
185
119
 
186
- # Full tool definition hash for LLM API calls.
187
- #
188
- # @return [Hash]
189
120
  def tool_definition
190
- defn = {
191
- name: name,
192
- description: description
193
- }
121
+ defn = { name: name, description: description }
194
122
  defn[:input_schema] = params_schema if params_schema
195
123
  defn
196
124
  end
197
125
 
198
- # @return [String] inspect string
199
126
  def inspect
200
127
  "#<#{self.class.name} name=#{name.inspect}>"
201
128
  end
202
129
 
203
130
  private
204
131
 
132
+ def params_schema_definition
133
+ self.class.instance_variable_get(:@params_schema_definition)
134
+ end
135
+
136
+ def resolve_params_schema(definition)
137
+ case definition
138
+ when Proc
139
+ schema_class = Ask::Schema.create(&definition)
140
+ schema_class.new.to_json_schema.dig(:schema)
141
+ when Hash then definition
142
+ when ->(d) { d.respond_to?(:to_json_schema) }
143
+ definition.to_json_schema.dig(:schema)
144
+ else nil
145
+ end
146
+ end
147
+
148
+ def build_schema_from_params
149
+ properties = parameters.to_h do |_name, param|
150
+ schema = { type: param.type }
151
+ schema[:description] = param.description if param.description
152
+ schema[:items] = { type: "string" } if param.type == "array"
153
+ [param.name.to_s, schema]
154
+ end
155
+ required = parameters.select { |_, p| p.required }.keys.map(&:to_s)
156
+ { type: "object", properties: properties, required: required, additionalProperties: false }
157
+ end
158
+
159
+ def deep_stringify_keys(obj)
160
+ case obj
161
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
162
+ when Array then obj.map { |v| deep_stringify_keys(v) }
163
+ else obj
164
+ end
165
+ end
166
+
205
167
  def normalize_args(args)
206
168
  return {} if args.nil?
207
-
208
169
  args.respond_to?(:transform_keys) ? args.transform_keys(&:to_sym) : {}
209
170
  end
210
171
 
211
172
  def validate(normalized)
173
+ return nil if params_schema_definition
212
174
  missing = self.class.parameters.select { |_, p| p.required && !normalized.key?(p.name) }
213
175
  return "missing required parameters: #{missing.keys.map(&:inspect).join(', ')}" unless missing.empty?
214
-
215
176
  unknown = normalized.keys - self.class.parameters.keys
216
177
  return "unknown parameters: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
217
-
218
178
  nil
219
179
  end
220
180
 
221
181
  VALID_JSON_SCHEMA_TYPES = %i[string integer number boolean array object].freeze
222
182
 
223
- def self.validate_param_type!(type, name)
224
- return if VALID_JSON_SCHEMA_TYPES.include?(type)
225
-
226
- raise ArgumentError,
227
- "Invalid type #{type.inspect} for parameter #{name.inspect}. " \
228
- "Valid types: #{VALID_JSON_SCHEMA_TYPES.map(&:inspect).join(', ')}"
229
- end
230
-
231
- def self.map_type(type)
232
- case type
233
- when :int then "integer"
234
- when :float, :double then "number"
235
- else type.to_s
236
- end
237
- end
238
-
239
- # Internal value object for parameter metadata.
240
183
  class Parameter
241
- # @return [Symbol]
242
- attr_reader :name
243
-
244
- # @return [String] JSON Schema type string
245
- attr_reader :type
246
-
247
- # @return [String, nil]
248
- attr_reader :description
249
-
250
- # @return [Boolean]
251
- attr_reader :required
252
-
184
+ attr_reader :name, :type, :description, :required
253
185
  alias required? required
254
-
255
186
  def initialize(name:, type:, description: nil, required: true)
256
- @name = name
257
- @type = type
258
- @description = description
259
- @required = required
187
+ @name = name; @type = type; @description = description; @required = required
260
188
  end
261
-
262
- # @return [Hash]
263
189
  def to_h
264
- {
265
- name: name,
266
- type: type,
267
- description: description,
268
- required: required
269
- }
190
+ { name: name, type: type, description: description, required: required }
270
191
  end
271
192
  end
272
193
  end
data/lib/ask/version.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ask
4
- VERSION = "0.1.0"
4
+ module Tools
5
+ VERSION = "0.1.1"
6
+ end
5
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-tools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto