rasti-ai 1.1.0 → 1.2.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 +4 -4
- data/.gitignore +2 -1
- data/README.md +151 -3
- data/lib/rasti/ai/mcp/client.rb +68 -0
- data/lib/rasti/ai/mcp/errors.rb +29 -0
- data/lib/rasti/ai/mcp/server.rb +156 -0
- data/lib/rasti/ai/open_ai/assistant.rb +25 -3
- data/lib/rasti/ai/tool.rb +23 -0
- data/lib/rasti/ai/tool_serializer.rb +112 -0
- data/lib/rasti/ai/version.rb +1 -1
- data/log/.gitkeep +0 -0
- data/rasti-ai.gemspec +2 -0
- data/spec/mcp/client_spec.rb +256 -0
- data/spec/mcp/server_spec.rb +372 -0
- data/spec/minitest_helper.rb +2 -0
- data/spec/open_ai/assistant_spec.rb +9 -2
- data/spec/{open_ai/tool_serializer_spec.rb → tool_serializer_spec.rb} +21 -29
- metadata +42 -5
- data/lib/rasti/ai/open_ai/tool_serializer.rb +0 -111
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f62b3decd7ee67906392eff6218e29fd8a66bee1f5dc41729e4412193f7ff4d0
|
|
4
|
+
data.tar.gz: 3368912d5ee86e2ba10408aa4795c97820b8f313a6d17d693deca2f0454ad9e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da0b274e1ed8e4d4c0d9d4f7867ca546f47bc13c48246e111a71f465719237abf11985384699097f19d61822a33575fdd2a3398c63b2bb12a4612b11e563cec6
|
|
7
|
+
data.tar.gz: 48618afd26f383ff8c38d7527c3aac4f44015c9812677c93a893c7d9ccbddc0ed8cd4f6ebb6309acac3c334effaffec6138fb72715f036a51083839129f44cab
|
data/.gitignore
CHANGED
data/README.md
CHANGED
|
@@ -96,12 +96,160 @@ state.messages
|
|
|
96
96
|
# ]
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
### MCP (Model Context Protocol)
|
|
100
|
+
|
|
101
|
+
Rasti::AI includes support for the Model Context Protocol, allowing you to create MCP servers and clients for tool communication.
|
|
102
|
+
|
|
103
|
+
#### MCP Server
|
|
104
|
+
|
|
105
|
+
The MCP Server acts as a Rack middleware that exposes registered tools through a JSON-RPC 2.0 interface.
|
|
106
|
+
|
|
107
|
+
##### Configuration
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
111
|
+
config.server_name = 'My MCP Server'
|
|
112
|
+
config.server_version = '1.0.0'
|
|
113
|
+
config.relative_path = '/mcp' # Default endpoint path
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
##### Registering Tools
|
|
118
|
+
|
|
119
|
+
Tools must inherit from `Rasti::AI::Tool` and can be registered with the server:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class HelloWorldTool < Rasti::AI::Tool
|
|
123
|
+
def self.description
|
|
124
|
+
'Returns a hello world message'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def execute(form)
|
|
128
|
+
{text: 'Hello world'}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class SumTool < Rasti::AI::Tool
|
|
133
|
+
class Form < Rasti::Form
|
|
134
|
+
attribute :number_a, Rasti::Types::Float
|
|
135
|
+
attribute :number_b, Rasti::Types::Float
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def execute(form)
|
|
139
|
+
{result: form.number_a + form.number_b}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Register tools
|
|
144
|
+
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
145
|
+
Rasti::AI::MCP::Server.register_tool SumTool.new
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
##### Using as Rack Middleware
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# In your config.ru
|
|
152
|
+
require 'rasti/ai'
|
|
153
|
+
|
|
154
|
+
# Register your tools
|
|
155
|
+
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
156
|
+
Rasti::AI::MCP::Server.register_tool SumTool.new
|
|
157
|
+
|
|
158
|
+
# Use as middleware
|
|
159
|
+
use Rasti::AI::MCP::Server
|
|
160
|
+
|
|
161
|
+
run YourApp
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The server will handle POST requests to the configured path (`/mcp` by default) and pass all other requests to your application.
|
|
165
|
+
|
|
166
|
+
##### Supported MCP Methods
|
|
167
|
+
|
|
168
|
+
- `initialize` - Returns protocol version and server capabilities
|
|
169
|
+
- `tools/list` - Returns all registered tools with their schemas
|
|
170
|
+
- `tools/call` - Executes a specific tool with provided arguments
|
|
171
|
+
|
|
172
|
+
#### MCP Client
|
|
173
|
+
|
|
174
|
+
The MCP Client allows you to communicate with MCP servers.
|
|
175
|
+
|
|
176
|
+
##### Basic Usage
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# Create a client
|
|
180
|
+
client = Rasti::AI::MCP::Client.new(
|
|
181
|
+
url: 'https://mcp.server.ai/mcp'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# List available tools
|
|
185
|
+
tools = client.list_tools
|
|
186
|
+
# => [
|
|
187
|
+
# { "name" => "hello_world_tool", "description" => "Hello World", ... },
|
|
188
|
+
# { "name" => "sum_tool", "description" => "Sum two numbers", ... }
|
|
189
|
+
# ]
|
|
190
|
+
|
|
191
|
+
# Call a tool
|
|
192
|
+
result = client.call_tool 'sum_tool', number_a: 5, number_b: 3
|
|
193
|
+
# => '{"type":"text","text":"{\"result\":8.0}"}'
|
|
194
|
+
|
|
195
|
+
result = client.call_tool 'hello_world_tool'
|
|
196
|
+
# => '{"type":"text","text":"{\"text\":\"Hello world\"}"}'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
##### Restricting Available Tools
|
|
200
|
+
|
|
201
|
+
You can restrict which tools the client can access:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
client = Rasti::AI::MCP::Client.new(
|
|
205
|
+
url: 'https://mcp.server.ai/mcp',
|
|
206
|
+
allowed_tools: ['sum_tool', 'multiply_tool']
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Only returns allowed tools
|
|
210
|
+
tools = client.list_tools
|
|
211
|
+
# => [{ "name" => "sum_tool", ... }]
|
|
212
|
+
|
|
213
|
+
# Calling a non-allowed tool raises an error
|
|
214
|
+
client.call_tool 'hello_world_tool'
|
|
215
|
+
# => RuntimeError: Invalid tool: hello_world_tool
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
##### Custom Logger
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
client = Rasti::AI::MCP::Client.new(
|
|
222
|
+
url: 'https://mcp.server.ai/mcp',
|
|
223
|
+
logger: Logger.new(STDOUT)
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
##### Integration with OpenAI Assistant
|
|
228
|
+
|
|
229
|
+
You can use MCP clients as tools for the OpenAI Assistant:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# Create an MCP client
|
|
233
|
+
mcp_client = Rasti::AI::MCP::Client.new(
|
|
234
|
+
url: 'https://mcp.server.ai/mcp'
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Use it with the assistant
|
|
238
|
+
assistant = Rasti::AI::OpenAI::Assistant.new(
|
|
239
|
+
mcp_servers: {
|
|
240
|
+
my_mcp: mcp_client
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# The assistant can now call tools from the MCP server
|
|
245
|
+
assistant.call 'What is 5 plus 3?'
|
|
246
|
+
# The assistant will use the sum_tool from the MCP server
|
|
247
|
+
```
|
|
248
|
+
|
|
99
249
|
## Contributing
|
|
100
250
|
|
|
101
251
|
Bug reports and pull requests are welcome on GitHub at https://github.com/gabynaiman/rasti-ai.
|
|
102
252
|
|
|
103
|
-
|
|
104
253
|
## License
|
|
105
254
|
|
|
106
|
-
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
|
107
|
-
|
|
255
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
|
|
6
|
+
def initialize(url:, allowed_tools:nil, logger:nil)
|
|
7
|
+
@url = url
|
|
8
|
+
@allowed_tools = allowed_tools
|
|
9
|
+
@logger = logger || Rasti::AI.logger
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def list_tools
|
|
13
|
+
result = request_mcp 'tools/list'
|
|
14
|
+
tools = result['tools']
|
|
15
|
+
if allowed_tools
|
|
16
|
+
tools.select { |tool| allowed_tools.include? tool['name'] }
|
|
17
|
+
else
|
|
18
|
+
tools
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call_tool(name, arguments={})
|
|
23
|
+
raise "Invalid tool: #{name}" if allowed_tools && !allowed_tools.include?(name)
|
|
24
|
+
result = request_mcp 'tools/call', name: name, arguments: arguments
|
|
25
|
+
JSON.dump result['content'][0]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :url, :allowed_tools, :logger
|
|
31
|
+
|
|
32
|
+
def request_mcp(method, params={})
|
|
33
|
+
uri = URI.parse url
|
|
34
|
+
|
|
35
|
+
http = Net::HTTP.new uri.host, uri.port
|
|
36
|
+
http.use_ssl = uri.scheme == 'https'
|
|
37
|
+
|
|
38
|
+
request = Net::HTTP::Post.new uri.path
|
|
39
|
+
|
|
40
|
+
request['Content-Type'] = 'application/json'
|
|
41
|
+
|
|
42
|
+
body = {
|
|
43
|
+
jsonrpc: '2.0',
|
|
44
|
+
method: method,
|
|
45
|
+
params: params
|
|
46
|
+
}
|
|
47
|
+
request.body = JSON.dump body
|
|
48
|
+
|
|
49
|
+
logger.info(self.class) { "POST #{url} -> #{method}" }
|
|
50
|
+
logger.debug(self.class) { JSON.pretty_generate(params) }
|
|
51
|
+
|
|
52
|
+
response = http.request request
|
|
53
|
+
|
|
54
|
+
logger.info(self.class) { "Response #{response.code}" }
|
|
55
|
+
logger.debug(self.class) { response.body }
|
|
56
|
+
|
|
57
|
+
json = JSON.parse response.body
|
|
58
|
+
|
|
59
|
+
raise "MCP Error: #{json['error']['message']}" if json['error']
|
|
60
|
+
raise "MCP Error: invalid result" unless json['result']
|
|
61
|
+
|
|
62
|
+
json['result']
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module MCP
|
|
4
|
+
|
|
5
|
+
# JSON-RPC oficiales
|
|
6
|
+
JSON_RPC_PARSE_ERROR = -32700
|
|
7
|
+
JSON_RPC_INVALID_REQUEST = -32600
|
|
8
|
+
JSON_RPC_METHOD_NOT_FOUND = -32601
|
|
9
|
+
JSON_RPC_INVALID_PARAMS = -32602
|
|
10
|
+
JSON_RPC_INTERNAL_ERROR = -32603
|
|
11
|
+
|
|
12
|
+
# JSON-RPC rango de servidor (permitido)
|
|
13
|
+
JSON_RPC_SERVER_ERROR = -32000
|
|
14
|
+
JSON_RPC_SERVER_NOT_FOUND = -32001
|
|
15
|
+
JSON_RPC_SERVER_UNAUTHORIZED = -32002
|
|
16
|
+
JSON_RPC_SERVER_FORBIDDEN = -32003
|
|
17
|
+
JSON_RPC_SERVER_RESOURCE_NOT_FOUND = -32004
|
|
18
|
+
JSON_RPC_SERVER_RATE_LIMIT = -32005
|
|
19
|
+
|
|
20
|
+
# MCP (usos comunes)
|
|
21
|
+
MCP_INVALID_ACCESS = -32001
|
|
22
|
+
MCP_TOOL_ERROR = -32002
|
|
23
|
+
MCP_FETCH_ERROR = -32003
|
|
24
|
+
MCP_RESOURCE_NOT_FOUND = -32004
|
|
25
|
+
MCP_TIMEOUT = -32005
|
|
26
|
+
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
module MCP
|
|
4
|
+
class Server
|
|
5
|
+
|
|
6
|
+
ToolSpecification = Rasti::Model[:tool, :serialization]
|
|
7
|
+
|
|
8
|
+
PROTOCOL_VERSION = '2024-11-05'.freeze
|
|
9
|
+
JSON_RPC_VERSION = '2.0'.freeze
|
|
10
|
+
|
|
11
|
+
extend ClassConfig
|
|
12
|
+
|
|
13
|
+
attr_config :server_name, 'MCP Server'
|
|
14
|
+
attr_config :server_version, '1.0.0'
|
|
15
|
+
attr_config :relative_path, '/mcp'
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
|
|
19
|
+
def register_tool(tool)
|
|
20
|
+
serialization = ToolSerializer.serialize tool.class
|
|
21
|
+
raise "Tool #{serialization[:name]} already exist" if tools.key? serialization[:name]
|
|
22
|
+
tools[serialization[:name]] = ToolSpecification.new tool: tool, serialization: serialization
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def clear_tools
|
|
26
|
+
tools.clear
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tools_serializations
|
|
30
|
+
tools.values.map(&:serialization)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call_tool(name, arguments={})
|
|
34
|
+
raise "Tool #{name} not found" unless tools.key? name
|
|
35
|
+
tools[name].tool.call arguments
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def tools
|
|
41
|
+
@tools ||= {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(app)
|
|
47
|
+
@app = app
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(env)
|
|
51
|
+
request = Rack::Request.new env
|
|
52
|
+
|
|
53
|
+
if request.post? && request.path == self.class.relative_path
|
|
54
|
+
handle_mcp_request request
|
|
55
|
+
else
|
|
56
|
+
app.call env
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :app
|
|
63
|
+
|
|
64
|
+
def handle_mcp_request(request)
|
|
65
|
+
body = request.body.read
|
|
66
|
+
data = JSON.parse body
|
|
67
|
+
|
|
68
|
+
response = case data['method']
|
|
69
|
+
when 'initialize'
|
|
70
|
+
handle_initialize data
|
|
71
|
+
when 'tools/list'
|
|
72
|
+
handle_tools_list data
|
|
73
|
+
when 'tools/call'
|
|
74
|
+
handle_tool_call data
|
|
75
|
+
else
|
|
76
|
+
error_response data['id'], JSON_RPC_METHOD_NOT_FOUND, 'Method not found'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
[200, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
|
|
80
|
+
|
|
81
|
+
rescue JSON::ParserError => e
|
|
82
|
+
response = error_response nil, JSON_RPC_PARSE_ERROR, e.message
|
|
83
|
+
[400, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
|
|
84
|
+
|
|
85
|
+
rescue => e
|
|
86
|
+
response = error_response nil, JSON_RPC_INTERNAL_ERROR, e.message
|
|
87
|
+
[500, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_initialize(data)
|
|
91
|
+
{
|
|
92
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
93
|
+
id: data['id'],
|
|
94
|
+
result: {
|
|
95
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
96
|
+
capabilities: {
|
|
97
|
+
tools: {
|
|
98
|
+
list: true,
|
|
99
|
+
call: true
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
serverInfo: {
|
|
103
|
+
name: self.class.server_name,
|
|
104
|
+
version: self.class.server_version
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_tools_list(data)
|
|
111
|
+
{
|
|
112
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
113
|
+
id: data['id'],
|
|
114
|
+
result: {
|
|
115
|
+
tools: self.class.tools_serializations
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def handle_tool_call(data)
|
|
121
|
+
tool_name = data['params']['name']
|
|
122
|
+
arguments = data['params']['arguments'] || {}
|
|
123
|
+
|
|
124
|
+
result = self.class.call_tool tool_name, arguments
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
128
|
+
id: data['id'],
|
|
129
|
+
result: {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: result
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
rescue => e
|
|
139
|
+
error_response data['id'], JSON_RPC_INTERNAL_ERROR, e.message
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def error_response(id, code, message)
|
|
143
|
+
{
|
|
144
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
145
|
+
id: id,
|
|
146
|
+
error: {
|
|
147
|
+
code: code,
|
|
148
|
+
message: message
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -5,7 +5,7 @@ module Rasti
|
|
|
5
5
|
|
|
6
6
|
attr_reader :state
|
|
7
7
|
|
|
8
|
-
def initialize(client:nil, json_schema:nil, state:nil, model:nil, tools:[], logger:nil)
|
|
8
|
+
def initialize(client:nil, json_schema:nil, state:nil, model:nil, tools:[], mcp_servers:{}, logger:nil)
|
|
9
9
|
@client = client || Client.new
|
|
10
10
|
@json_schema = json_schema
|
|
11
11
|
@state = state || AssistantState.new
|
|
@@ -15,10 +15,20 @@ module Rasti
|
|
|
15
15
|
@logger = logger || Rasti::AI.logger
|
|
16
16
|
|
|
17
17
|
tools.each do |tool|
|
|
18
|
-
serialization =
|
|
18
|
+
serialization = serialize_tool tool
|
|
19
19
|
@tools[serialization[:function][:name]] = tool
|
|
20
20
|
@serialized_tools << serialization
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
mcp_servers.each do |name, mcp|
|
|
24
|
+
mcp.list_tools.each do |tool|
|
|
25
|
+
serialization = wrap_tool_serialization tool.merge('name' => "#{name}_#{tool['name']}")
|
|
26
|
+
@tools["#{name}_#{tool['name']}"] = ->(args) do
|
|
27
|
+
mcp.call_tool tool['name'], args
|
|
28
|
+
end
|
|
29
|
+
@serialized_tools << serialization
|
|
30
|
+
end
|
|
31
|
+
end
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def call(prompt)
|
|
@@ -72,6 +82,18 @@ module Rasti
|
|
|
72
82
|
state.messages
|
|
73
83
|
end
|
|
74
84
|
|
|
85
|
+
def serialize_tool(tool)
|
|
86
|
+
serialization = ToolSerializer.serialize tool.class
|
|
87
|
+
wrap_tool_serialization serialization
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def wrap_tool_serialization(serialized_tool)
|
|
91
|
+
{
|
|
92
|
+
type: 'function',
|
|
93
|
+
function: serialized_tool
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
75
97
|
def call_tool(name, args)
|
|
76
98
|
raise Errors::UndefinedTool.new(name) unless tools.key? name
|
|
77
99
|
|
|
@@ -104,4 +126,4 @@ module Rasti
|
|
|
104
126
|
end
|
|
105
127
|
end
|
|
106
128
|
end
|
|
107
|
-
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
class Tool
|
|
4
|
+
|
|
5
|
+
def self.form
|
|
6
|
+
constants.include?(:Form) ? const_get(:Form) : Rasti::Form
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(params={})
|
|
10
|
+
form = self.class.form.new params
|
|
11
|
+
result = execute form
|
|
12
|
+
serialize result
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def serialize(result)
|
|
18
|
+
JSON.dump result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Rasti
|
|
2
|
+
module AI
|
|
3
|
+
class ToolSerializer
|
|
4
|
+
class << self
|
|
5
|
+
|
|
6
|
+
def serialize(tool_class)
|
|
7
|
+
serialization = {
|
|
8
|
+
name: serialize_name(tool_class)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
serialization[:description] = normalize_description(tool_class.description) if tool_class.respond_to? :description
|
|
12
|
+
|
|
13
|
+
serialization[:inputSchema] = serialize_form(tool_class.form) if tool_class.respond_to? :form
|
|
14
|
+
|
|
15
|
+
serialization
|
|
16
|
+
|
|
17
|
+
rescue => ex
|
|
18
|
+
raise Errors::ToolSerializationError.new(tool_class), cause: ex
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def serialize_name(tool_class)
|
|
24
|
+
Inflecto.underscore Inflecto.demodulize(tool_class.name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def serialize_form(form_class)
|
|
28
|
+
serialized_attributes = form_class.attributes.each_with_object({}) do |attribute, hash|
|
|
29
|
+
hash[attribute.name] = serialize_attribute attribute
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
serialization = {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: serialized_attributes
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
required_attributes = form_class.attributes.select { |a| a.option(:required) }
|
|
38
|
+
|
|
39
|
+
serialization[:required] = required_attributes.map(&:name) unless required_attributes.empty?
|
|
40
|
+
|
|
41
|
+
serialization
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serialize_attribute(attribute)
|
|
45
|
+
serialization = {}
|
|
46
|
+
|
|
47
|
+
if attribute.option(:description)
|
|
48
|
+
serialization[:description] = normalize_description attribute.option(:description)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
type_serialization = serialize_type attribute.type
|
|
52
|
+
serialization.merge! type_serialization
|
|
53
|
+
|
|
54
|
+
if attribute.type.is_a? Types::Enum
|
|
55
|
+
values = "#{type_serialization[:enum].join(', ')}"
|
|
56
|
+
if serialization[:description]
|
|
57
|
+
serialization[:description] += " (#{values})"
|
|
58
|
+
else
|
|
59
|
+
serialization[:description] = values
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
serialization
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def serialize_type(type)
|
|
67
|
+
if type == Types::String
|
|
68
|
+
{type: 'string'}
|
|
69
|
+
|
|
70
|
+
elsif type == Types::Integer
|
|
71
|
+
{type: 'integer'}
|
|
72
|
+
|
|
73
|
+
elsif type == Types::Float
|
|
74
|
+
{type: 'number'}
|
|
75
|
+
|
|
76
|
+
elsif type == Types::Boolean
|
|
77
|
+
{type: 'boolean'}
|
|
78
|
+
|
|
79
|
+
elsif type.is_a? Types::Time
|
|
80
|
+
{
|
|
81
|
+
type: 'string',
|
|
82
|
+
format: 'date'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
elsif type.is_a? Types::Enum
|
|
86
|
+
{
|
|
87
|
+
type: 'string',
|
|
88
|
+
enum: type.values
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
elsif type.is_a? Types::Array
|
|
92
|
+
{
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: serialize_type(type.type)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
elsif type.is_a? Types::Model
|
|
98
|
+
serialize_form(type.model)
|
|
99
|
+
|
|
100
|
+
else
|
|
101
|
+
raise "Type not serializable #{type}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_description(description)
|
|
106
|
+
description.split("\n").map(&:strip).join(' ').strip
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/rasti/ai/version.rb
CHANGED
data/log/.gitkeep
ADDED
|
File without changes
|
data/rasti-ai.gemspec
CHANGED
|
@@ -24,9 +24,11 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
spec.add_runtime_dependency 'class_config', '~> 0.0'
|
|
25
25
|
|
|
26
26
|
spec.add_development_dependency 'rake', '~> 12.0'
|
|
27
|
+
spec.add_development_dependency 'rack-test', '~> 2.0'
|
|
27
28
|
spec.add_development_dependency 'minitest', '~> 5.0', '< 5.11'
|
|
28
29
|
spec.add_development_dependency 'minitest-colorin', '~> 0.1'
|
|
29
30
|
spec.add_development_dependency 'minitest-line', '~> 0.6'
|
|
31
|
+
spec.add_development_dependency 'minitest-extended_assertions', '~> 1.0'
|
|
30
32
|
spec.add_development_dependency 'simplecov', '~> 0.12'
|
|
31
33
|
spec.add_development_dependency 'pry-nav', '~> 0.2'
|
|
32
34
|
spec.add_development_dependency 'webmock', '~> 3.0'
|