ask-tools 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +205 -0
- data/lib/ask/tools/result.rb +81 -0
- data/lib/ask/tools/tool.rb +273 -0
- data/lib/ask/tools.rb +87 -0
- data/lib/ask/version.rb +5 -0
- data/lib/ask-tools.rb +9 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 41146df4fa3ec2cec15d6085c09d14204cedba968ea1afab6afa72cadf2ec971
|
|
4
|
+
data.tar.gz: 7592284b53e742c81654504d8e9c4b25e8f93c7876cd2a6b6fad2cc7dc8aa0c3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8de6e0c216ab3432b59c016e0edcee9a2de8e303e83b73655e49d7b75a71c08deff8dc22ea917d2ebd9c6f30f667dc6aede63c3fce61bcbce30a10cc4e09c127
|
|
7
|
+
data.tar.gz: f7b0709e856f0f4e6eceaf21e7117bb59095fd6eebba267559fe0651468932b20e3d926feab4509145e4053ede86a41b70fcdc45deca176f2cb84dbfba9b0f84
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# ask-tools
|
|
2
|
+
|
|
3
|
+
The foundational gem for the ask-rb ecosystem. Defines `Ask::Tool` — the base class every tool inherits from — along with `Ask::Result` (standardized return value), tool discovery/registration, and a scaffold generator. **Zero external dependencies.**
|
|
4
|
+
|
|
5
|
+
This gem does **not** ship any executable tools. It only provides the contract that tool gems (e.g., `ask-tools-shell`, `ask-tools-filesystem`) implement.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add this line to your `Gemfile`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "ask-tools"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install it directly:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gem install ask-tools
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "ask-tools"
|
|
25
|
+
|
|
26
|
+
class Greeter < Ask::Tool
|
|
27
|
+
description "Greets a person by name"
|
|
28
|
+
param :name, type: :string, desc: "The person's name", required: true
|
|
29
|
+
|
|
30
|
+
def execute(name:)
|
|
31
|
+
Ask::Result.ok(data: "Hello, #{name}!")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Use it
|
|
36
|
+
tool = Greeter.new
|
|
37
|
+
tool.name # => "greeter"
|
|
38
|
+
tool.description # => "Greets a person by name"
|
|
39
|
+
|
|
40
|
+
result = tool.call(name: "World")
|
|
41
|
+
result.ok? # => true
|
|
42
|
+
result.output # => "Hello, World!"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API Reference
|
|
46
|
+
|
|
47
|
+
### `Ask::Tool` — Base Class
|
|
48
|
+
|
|
49
|
+
Subclass `Ask::Tool` to define a tool that an LLM can call.
|
|
50
|
+
|
|
51
|
+
#### Class DSL
|
|
52
|
+
|
|
53
|
+
| Method | Description |
|
|
54
|
+
|--------|-------------|
|
|
55
|
+
| `description(text)` | Sets or retrieves the tool's human-readable description. Alias: `desc` |
|
|
56
|
+
| `param(name, type:, desc:, required:)` | Declares a parameter. `type` must be a valid JSON Schema type (`:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`) |
|
|
57
|
+
|
|
58
|
+
#### Instance Methods
|
|
59
|
+
|
|
60
|
+
| Method | Returns | Description |
|
|
61
|
+
|--------|---------|-------------|
|
|
62
|
+
| `name` | `String` | Auto-derived from the class name: CamelCase → snake_case, strips `_tool` suffix |
|
|
63
|
+
| `description` | `String?, nil` | The tool's description |
|
|
64
|
+
| `parameters` | `Hash{Symbol => Parameter}` | Declared parameter definitions |
|
|
65
|
+
| `call(args = {})` | `Ask::Result` | Normalizes args (symbolizes keys), validates required params, delegates to `execute`. Catches `Halt` and `StandardError` |
|
|
66
|
+
| `execute(**args)` | `Ask::Result` | **Override this.** Implement the tool's logic. |
|
|
67
|
+
| `params_schema` | `Hash?, nil` | JSON Schema hash for LLM function-calling APIs. Returns `nil` when no params declared |
|
|
68
|
+
| `tool_definition` | `Hash` | Full tool definition hash with `:name`, `:description`, and `:input_schema` |
|
|
69
|
+
|
|
70
|
+
#### Error Handling
|
|
71
|
+
|
|
72
|
+
- **`Ask::Tool::Halt`** — Raise this inside `execute` to signal the conversation loop should stop after this tool's result. `call` returns an `Ask::Result` with `metadata[:halted] = true`.
|
|
73
|
+
- **`StandardError`** — Any other exception raised in `execute` is caught by `call` and returned as an error `Ask::Result`.
|
|
74
|
+
|
|
75
|
+
### `Ask::Result` — Return Value
|
|
76
|
+
|
|
77
|
+
A value object representing the outcome of a tool execution.
|
|
78
|
+
|
|
79
|
+
#### Factory Methods
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# Successful result
|
|
83
|
+
Ask::Result.ok(data: "output", metadata: { key: "val" })
|
|
84
|
+
|
|
85
|
+
# Failed result
|
|
86
|
+
Ask::Result.error(message: "Something went wrong", metadata: { code: 500 })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Attributes
|
|
90
|
+
|
|
91
|
+
| Attribute | Type | Description |
|
|
92
|
+
|-----------|------|-------------|
|
|
93
|
+
| `ok?` / `ok` | `Boolean` | Whether the tool completed successfully |
|
|
94
|
+
| `output` | `Object?, nil` | Output data (success) |
|
|
95
|
+
| `error` | `String?, nil` | Error message (failure) |
|
|
96
|
+
| `metadata` | `Hash` | Arbitrary metadata |
|
|
97
|
+
|
|
98
|
+
#### Instance Methods
|
|
99
|
+
|
|
100
|
+
| Method | Returns | Description |
|
|
101
|
+
|--------|---------|-------------|
|
|
102
|
+
| `to_s` | `String` | Returns `output.to_s` for success, `error` for failure |
|
|
103
|
+
| `to_h` | `Hash` | Serialized hash with `:ok`, `:output`, `:error`, `:metadata` |
|
|
104
|
+
| `inspect` | `String` | Human-readable representation |
|
|
105
|
+
|
|
106
|
+
### `Ask::Tool::Parameter` — Parameter Definition
|
|
107
|
+
|
|
108
|
+
Internal value object describing a declared parameter. Accessible via `Tool.parameters[name]`.
|
|
109
|
+
|
|
110
|
+
| Attribute | Type | Description |
|
|
111
|
+
|-----------|------|-------------|
|
|
112
|
+
| `name` | `Symbol` | Parameter name |
|
|
113
|
+
| `type` | `String` | JSON Schema type string |
|
|
114
|
+
| `description` | `String?, nil` | Human-readable description |
|
|
115
|
+
| `required` / `required?` | `Boolean` | Whether the parameter is mandatory |
|
|
116
|
+
|
|
117
|
+
### `Ask::Tools` — Registry & Discovery
|
|
118
|
+
|
|
119
|
+
Central registry for tool classes.
|
|
120
|
+
|
|
121
|
+
| Method | Returns | Description |
|
|
122
|
+
|--------|---------|-------------|
|
|
123
|
+
| `.register(tool_class)` | `void` | Manually register a tool class |
|
|
124
|
+
| `.all` | `Array<Tool>` | Instantiated list of all registered tools |
|
|
125
|
+
| `.discover` | `Array<Class>` | Auto-discover loaded `Ask::Tool` subclasses via `ObjectSpace` |
|
|
126
|
+
| `.[](name)` | `Tool?, nil` | Find a registered tool by its derived name |
|
|
127
|
+
| `.clear` | `void` | Remove all registered tools |
|
|
128
|
+
| `.count` | `Integer` | Number of registered tool classes |
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Manual registration
|
|
132
|
+
Ask::Tools.register(MyTool)
|
|
133
|
+
|
|
134
|
+
# Auto-discover all loaded Ask::Tool subclasses
|
|
135
|
+
Ask::Tools.discover
|
|
136
|
+
|
|
137
|
+
# Find by name
|
|
138
|
+
tool = Ask::Tools["my_tool"]
|
|
139
|
+
tool.call(input: "hello")
|
|
140
|
+
|
|
141
|
+
# List all
|
|
142
|
+
Ask::Tools.all.each { |t| puts t.name }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Defining a Custom Tool
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
class SearchTool < Ask::Tool
|
|
149
|
+
description "Searches a knowledge base"
|
|
150
|
+
param :query, type: :string, desc: "Search query", required: true
|
|
151
|
+
param :limit, type: :integer, desc: "Max results", required: false
|
|
152
|
+
|
|
153
|
+
def execute(query:, limit: 10)
|
|
154
|
+
results = perform_search(query, limit)
|
|
155
|
+
Ask::Result.ok(data: results)
|
|
156
|
+
rescue SearchError => e
|
|
157
|
+
Ask::Result.error(message: e.message)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def perform_search(query, limit)
|
|
163
|
+
# ... implementation
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Install dependencies
|
|
172
|
+
bundle install
|
|
173
|
+
|
|
174
|
+
# Run tests
|
|
175
|
+
bundle exec rake test
|
|
176
|
+
|
|
177
|
+
# Build the gem
|
|
178
|
+
gem build ask-tools.gemspec
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
ask-tools uses **Minitest** with **Mocha** for mocking.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
# Run the full test suite
|
|
187
|
+
bundle exec rake test
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Release Process
|
|
191
|
+
|
|
192
|
+
1. Update `CHANGELOG.md`
|
|
193
|
+
2. Update `lib/ask/version.rb` if needed
|
|
194
|
+
3. Build the gem: `gem build ask-tools.gemspec`
|
|
195
|
+
4. Push to GitHub Packages: `gem push ask-tools-*.gem`
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT — see [LICENSE](LICENSE).
|
|
200
|
+
|
|
201
|
+
## Links
|
|
202
|
+
|
|
203
|
+
- **Source:** https://github.com/ask-rb/ask-tools
|
|
204
|
+
- **Issues:** https://github.com/ask-rb/ask-tools/issues
|
|
205
|
+
- **Docs:** https://github.com/ask-rb/ask-docs
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
# Standardized return value for tool execution.
|
|
5
|
+
#
|
|
6
|
+
# Every tool's #execute method should return an Ask::Result.
|
|
7
|
+
# Use the factory methods +.ok+ and +.error+ for common cases.
|
|
8
|
+
#
|
|
9
|
+
# Ask::Result.ok(data: "hello world")
|
|
10
|
+
# Ask::Result.error(message: "something went wrong")
|
|
11
|
+
#
|
|
12
|
+
class Result
|
|
13
|
+
# @return [Boolean] whether the tool completed successfully
|
|
14
|
+
attr_reader :ok
|
|
15
|
+
|
|
16
|
+
# @return [Object, nil] the output data when the tool succeeded
|
|
17
|
+
attr_reader :output
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] the error message when the tool failed
|
|
20
|
+
attr_reader :error
|
|
21
|
+
|
|
22
|
+
# @return [Hash] arbitrary metadata attached to the result
|
|
23
|
+
attr_reader :metadata
|
|
24
|
+
|
|
25
|
+
alias ok? ok
|
|
26
|
+
|
|
27
|
+
def initialize(ok:, output: nil, error: nil, metadata: {})
|
|
28
|
+
@ok = ok
|
|
29
|
+
@output = output
|
|
30
|
+
@error = error
|
|
31
|
+
@metadata = metadata
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create a successful result.
|
|
35
|
+
#
|
|
36
|
+
# @param data [Object] the tool's output
|
|
37
|
+
# @param metadata [Hash] optional metadata
|
|
38
|
+
# @return [Ask::Result]
|
|
39
|
+
def self.ok(data:, metadata: {})
|
|
40
|
+
new(ok: true, output: data, error: nil, metadata: metadata)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create a failed result.
|
|
44
|
+
#
|
|
45
|
+
# @param message [String] description of the failure
|
|
46
|
+
# @param metadata [Hash] optional metadata
|
|
47
|
+
# @return [Ask::Result]
|
|
48
|
+
def self.error(message:, metadata: {})
|
|
49
|
+
new(ok: false, output: nil, error: message, metadata: metadata)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Human-readable representation.
|
|
53
|
+
# Returns the output for success or the error message for failure.
|
|
54
|
+
#
|
|
55
|
+
# @return [String]
|
|
56
|
+
def to_s
|
|
57
|
+
ok? ? output.to_s : error.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Hash representation suitable for serialization.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
def to_h
|
|
64
|
+
{
|
|
65
|
+
ok: ok,
|
|
66
|
+
output: output,
|
|
67
|
+
error: error,
|
|
68
|
+
metadata: metadata
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String] inspect string
|
|
73
|
+
def inspect
|
|
74
|
+
if ok?
|
|
75
|
+
"#<Ask::Result ok=true output=#{output.inspect}>"
|
|
76
|
+
else
|
|
77
|
+
"#<Ask::Result ok=false error=#{error.inspect}>"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
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
|
+
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
|
+
class Halt < StandardError
|
|
26
|
+
attr_reader :content
|
|
27
|
+
|
|
28
|
+
def initialize(content)
|
|
29
|
+
@content = content
|
|
30
|
+
super(content.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# @api private
|
|
36
|
+
def inherited(subclass)
|
|
37
|
+
super
|
|
38
|
+
@parameters = {} if @parameters.nil?
|
|
39
|
+
subclass.instance_variable_set(:@description, nil)
|
|
40
|
+
subclass.instance_variable_set(:@parameters, {})
|
|
41
|
+
end
|
|
42
|
+
|
|
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
|
+
def description(text = nil)
|
|
48
|
+
return @description unless text
|
|
49
|
+
|
|
50
|
+
@description = text
|
|
51
|
+
end
|
|
52
|
+
alias desc description
|
|
53
|
+
|
|
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
|
+
def param(name, type:, desc: nil, description: nil, required: true)
|
|
63
|
+
type = type.to_s.downcase.to_sym
|
|
64
|
+
validate_param_type!(type, name)
|
|
65
|
+
parameters[name] = Parameter.new(
|
|
66
|
+
name: name,
|
|
67
|
+
type: map_type(type),
|
|
68
|
+
description: desc || description,
|
|
69
|
+
required: required
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
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
|
+
def params(schema = nil, &block)
|
|
95
|
+
@params_schema_definition = schema || block
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def provider_params
|
|
99
|
+
@provider_params ||= {}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
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
|
+
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
|
|
110
|
+
normalized = klass_name.dup.force_encoding("UTF-8").unicode_normalize(:nfkd)
|
|
111
|
+
normalized.encode("ASCII", replace: "")
|
|
112
|
+
.gsub(/[^a-zA-Z0-9_-]/, "-")
|
|
113
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
114
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
115
|
+
.downcase
|
|
116
|
+
.delete_suffix("_tool")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [String, nil] the tool's description
|
|
120
|
+
def description
|
|
121
|
+
self.class.description
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Hash{Symbol => Ask::Tool::Parameter}]
|
|
125
|
+
def parameters
|
|
126
|
+
self.class.parameters
|
|
127
|
+
end
|
|
128
|
+
|
|
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
|
+
def call(args = {})
|
|
137
|
+
normalized = normalize_args(args)
|
|
138
|
+
validation = validate(normalized)
|
|
139
|
+
return Ask::Result.error(message: validation) if validation
|
|
140
|
+
|
|
141
|
+
execute(**normalized)
|
|
142
|
+
rescue Halt => e
|
|
143
|
+
Ask::Result.ok(data: e.content, metadata: { halted: true })
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
Ask::Result.error(message: "#{self.class.name.split('::').last} raised #{e.class}: #{e.message}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Subclasses must implement this method.
|
|
149
|
+
#
|
|
150
|
+
# @param args [Hash] normalized keyword arguments
|
|
151
|
+
# @return [Ask::Result] the tool's result
|
|
152
|
+
def execute(**)
|
|
153
|
+
raise NotImplementedError, "#{self.class} must implement #execute(**args)"
|
|
154
|
+
end
|
|
155
|
+
|
|
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
|
+
def params_schema
|
|
161
|
+
return @params_schema if defined?(@params_schema)
|
|
162
|
+
|
|
163
|
+
@params_schema = begin
|
|
164
|
+
if parameters.empty?
|
|
165
|
+
nil
|
|
166
|
+
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
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Full tool definition hash for LLM API calls.
|
|
187
|
+
#
|
|
188
|
+
# @return [Hash]
|
|
189
|
+
def tool_definition
|
|
190
|
+
defn = {
|
|
191
|
+
name: name,
|
|
192
|
+
description: description
|
|
193
|
+
}
|
|
194
|
+
defn[:input_schema] = params_schema if params_schema
|
|
195
|
+
defn
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @return [String] inspect string
|
|
199
|
+
def inspect
|
|
200
|
+
"#<#{self.class.name} name=#{name.inspect}>"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def normalize_args(args)
|
|
206
|
+
return {} if args.nil?
|
|
207
|
+
|
|
208
|
+
args.respond_to?(:transform_keys) ? args.transform_keys(&:to_sym) : {}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate(normalized)
|
|
212
|
+
missing = self.class.parameters.select { |_, p| p.required && !normalized.key?(p.name) }
|
|
213
|
+
return "missing required parameters: #{missing.keys.map(&:inspect).join(', ')}" unless missing.empty?
|
|
214
|
+
|
|
215
|
+
unknown = normalized.keys - self.class.parameters.keys
|
|
216
|
+
return "unknown parameters: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
|
|
217
|
+
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
VALID_JSON_SCHEMA_TYPES = %i[string integer number boolean array object].freeze
|
|
222
|
+
|
|
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
|
+
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
|
+
|
|
253
|
+
alias required? required
|
|
254
|
+
|
|
255
|
+
def initialize(name:, type:, description: nil, required: true)
|
|
256
|
+
@name = name
|
|
257
|
+
@type = type
|
|
258
|
+
@description = description
|
|
259
|
+
@required = required
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @return [Hash]
|
|
263
|
+
def to_h
|
|
264
|
+
{
|
|
265
|
+
name: name,
|
|
266
|
+
type: type,
|
|
267
|
+
description: description,
|
|
268
|
+
required: required
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/lib/ask/tools.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
# Tool registry and discovery.
|
|
7
|
+
#
|
|
8
|
+
# Provides a central registry for tool classes and auto-discovery
|
|
9
|
+
# of Ask::Tool subclasses via ObjectSpace. Thread-safe.
|
|
10
|
+
#
|
|
11
|
+
# Ask::Tools.register(MyTool)
|
|
12
|
+
# Ask::Tools.all # => [MyTool.new, ...]
|
|
13
|
+
# Ask::Tools["my_tool"] # => instance of MyTool
|
|
14
|
+
# Ask::Tools.discover # auto-register all loaded Ask::Tool subclasses
|
|
15
|
+
#
|
|
16
|
+
module Tools
|
|
17
|
+
class << self
|
|
18
|
+
# Register a tool class manually.
|
|
19
|
+
#
|
|
20
|
+
# @param tool_class [Class < Ask::Tool]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def register(tool_class)
|
|
23
|
+
monitor.synchronize { registry[tool_class.name] = tool_class }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Return an array of instantiated registered tools.
|
|
27
|
+
#
|
|
28
|
+
# @return [Array<Ask::Tool>]
|
|
29
|
+
def all
|
|
30
|
+
monitor.synchronize { registry.values.map(&:new) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Auto-discover loaded +Ask::Tool+ subclasses via +ObjectSpace+
|
|
34
|
+
# and register any that aren't already registered.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<Class>] the newly discovered classes
|
|
37
|
+
def discover
|
|
38
|
+
monitor.synchronize do
|
|
39
|
+
discovered = ObjectSpace.each_object(Class).select do |klass|
|
|
40
|
+
klass < Ask::Tool && !registry.value?(klass) && klass.name
|
|
41
|
+
end
|
|
42
|
+
discovered.each { |klass| register(klass) }
|
|
43
|
+
discovered
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Find a registered tool by its derived name.
|
|
48
|
+
#
|
|
49
|
+
# @param name [String, Symbol] the tool name to look up
|
|
50
|
+
# @return [Ask::Tool, nil] an instance of the matching tool, or nil
|
|
51
|
+
def [](name)
|
|
52
|
+
name_str = name.to_s
|
|
53
|
+
monitor.synchronize do
|
|
54
|
+
registry.each_value do |klass|
|
|
55
|
+
instance = klass.new
|
|
56
|
+
return instance if instance.name == name_str
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Remove all registered tools.
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def clear
|
|
66
|
+
monitor.synchronize { registry.clear }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Number of registered tool classes.
|
|
70
|
+
#
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
def count
|
|
73
|
+
monitor.synchronize { registry.size }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def registry
|
|
79
|
+
@registry ||= {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def monitor
|
|
83
|
+
@monitor ||= Monitor.new
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/ask/version.rb
ADDED
data/lib/ask-tools.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-tools
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.25'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.25'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: mocha
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.1'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
description: Defines Ask::Tool (base class), Ask::Result, and tool discovery. Zero
|
|
55
|
+
dependencies.
|
|
56
|
+
email:
|
|
57
|
+
- kaka@myrrlabs.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- LICENSE
|
|
63
|
+
- README.md
|
|
64
|
+
- lib/ask-tools.rb
|
|
65
|
+
- lib/ask/tools.rb
|
|
66
|
+
- lib/ask/tools/result.rb
|
|
67
|
+
- lib/ask/tools/tool.rb
|
|
68
|
+
- lib/ask/version.rb
|
|
69
|
+
homepage: https://github.com/ask-rb/ask-tools
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata: {}
|
|
73
|
+
rdoc_options: []
|
|
74
|
+
require_paths:
|
|
75
|
+
- lib
|
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '3.2'
|
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '0'
|
|
86
|
+
requirements: []
|
|
87
|
+
rubygems_version: 4.0.3
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: Tool framework for the ask-rb ecosystem
|
|
90
|
+
test_files: []
|