rasti-ai 2.0.2 → 3.0.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/.github/workflows/ci.yml +4 -20
- data/AGENTS.md +614 -0
- data/README.md +133 -25
- data/Rakefile +2 -0
- data/lib/rasti/ai/anthropic/assistant.rb +139 -0
- data/lib/rasti/ai/anthropic/client.rb +58 -0
- data/lib/rasti/ai/anthropic/roles.rb +12 -0
- data/lib/rasti/ai/assistant.rb +8 -3
- data/lib/rasti/ai/gemini/assistant.rb +42 -12
- data/lib/rasti/ai/mcp/client.rb +60 -9
- data/lib/rasti/ai/mcp/{errors.rb → constants.rb} +4 -1
- data/lib/rasti/ai/mcp/server.rb +42 -47
- data/lib/rasti/ai/mcp/tools_registry.rb +64 -0
- data/lib/rasti/ai/open_ai/assistant.rb +9 -4
- data/lib/rasti/ai/open_ai/client.rb +3 -2
- data/lib/rasti/ai/tool_serializer.rb +35 -62
- data/lib/rasti/ai/version.rb +1 -1
- data/lib/rasti/ai.rb +10 -0
- data/rasti-ai.gemspec +4 -1
- data/spec/anthropic/assistant_spec.rb +349 -0
- data/spec/anthropic/client_spec.rb +203 -0
- data/spec/gemini/assistant_spec.rb +15 -0
- data/spec/mcp/client_spec.rb +3 -1
- data/spec/mcp/server_spec.rb +195 -136
- data/spec/mcp/tools_registry_spec.rb +226 -0
- data/spec/minitest_helper.rb +29 -0
- data/spec/open_ai/assistant_spec.rb +20 -4
- data/spec/resources/anthropic/basic_request.json +1 -0
- data/spec/resources/anthropic/basic_response.json +20 -0
- data/spec/resources/anthropic/tool_request.json +1 -0
- data/spec/resources/anthropic/tool_response.json +22 -0
- data/spec/tool_serializer_spec.rb +31 -6
- data/tasks/assistant.rake +94 -0
- metadata +46 -6
data/spec/mcp/server_spec.rb
CHANGED
|
@@ -2,42 +2,20 @@ require 'minitest_helper'
|
|
|
2
2
|
|
|
3
3
|
describe Rasti::AI::MCP::Server do
|
|
4
4
|
|
|
5
|
-
class HelloWorldTool < Rasti::AI::Tool
|
|
6
|
-
def self.description
|
|
7
|
-
'Hello World'
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def execute(form)
|
|
11
|
-
{text: 'Hello world'}
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
class SumTool < Rasti::AI::Tool
|
|
16
|
-
class Form < Rasti::Form
|
|
17
|
-
attribute :number_a, Rasti::Types::Float
|
|
18
|
-
attribute :number_b, Rasti::Types::Float
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def execute(form)
|
|
22
|
-
{result: form.number_a + form.number_b}
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
5
|
class ErrorTool < Rasti::AI::Tool
|
|
27
6
|
def execute(form)
|
|
28
|
-
raise
|
|
7
|
+
raise 'Unexpected tool error'
|
|
29
8
|
end
|
|
30
9
|
end
|
|
31
10
|
|
|
32
11
|
include Rack::Test::Methods
|
|
33
12
|
|
|
34
13
|
let(:app) do
|
|
35
|
-
|
|
36
|
-
Rasti::AI::MCP::Server.new
|
|
14
|
+
inner = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['App response']] }
|
|
15
|
+
Rasti::AI::MCP::Server.new inner
|
|
37
16
|
end
|
|
38
17
|
|
|
39
18
|
before do
|
|
40
|
-
Rasti::AI::MCP::Server.clear_tools
|
|
41
19
|
Rasti::AI::MCP::Server.restore_default_configuration
|
|
42
20
|
end
|
|
43
21
|
|
|
@@ -49,7 +27,7 @@ describe Rasti::AI::MCP::Server do
|
|
|
49
27
|
}
|
|
50
28
|
request_data[:params] = params unless params.empty?
|
|
51
29
|
|
|
52
|
-
post Rasti::AI::MCP::Server.relative_path, JSON.dump(request_data), CONTENT_TYPE
|
|
30
|
+
post Rasti::AI::MCP::Server.relative_path, JSON.dump(request_data), 'CONTENT_TYPE' => 'application/json'
|
|
53
31
|
end
|
|
54
32
|
|
|
55
33
|
def assert_jsonrpc_success(expected_result)
|
|
@@ -58,7 +36,7 @@ describe Rasti::AI::MCP::Server do
|
|
|
58
36
|
id: 1,
|
|
59
37
|
result: expected_result
|
|
60
38
|
}
|
|
61
|
-
|
|
39
|
+
|
|
62
40
|
assert_equal 200, last_response.status
|
|
63
41
|
assert_equal 'application/json', last_response.content_type
|
|
64
42
|
assert_equal_json JSON.dump(expected_response), last_response.body
|
|
@@ -70,60 +48,12 @@ describe Rasti::AI::MCP::Server do
|
|
|
70
48
|
id: 1,
|
|
71
49
|
error: expected_error
|
|
72
50
|
}
|
|
73
|
-
|
|
51
|
+
|
|
74
52
|
assert_equal 200, last_response.status
|
|
75
53
|
assert_equal 'application/json', last_response.content_type
|
|
76
54
|
assert_equal_json JSON.dump(expected_response), last_response.body
|
|
77
55
|
end
|
|
78
56
|
|
|
79
|
-
describe 'Tool registration' do
|
|
80
|
-
|
|
81
|
-
it 'Register tool' do
|
|
82
|
-
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
83
|
-
Rasti::AI::MCP::Server.register_tool SumTool.new
|
|
84
|
-
|
|
85
|
-
serializations = Rasti::AI::MCP::Server.tools_serializations
|
|
86
|
-
|
|
87
|
-
expeted_serializations = [
|
|
88
|
-
Rasti::AI::ToolSerializer.serialize(HelloWorldTool),
|
|
89
|
-
Rasti::AI::ToolSerializer.serialize(SumTool)
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
assert_equal expeted_serializations, serializations
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
it 'Register duplicate tool raises error' do
|
|
96
|
-
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
97
|
-
|
|
98
|
-
error = assert_raises RuntimeError do
|
|
99
|
-
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
assert_equal 'Tool hello_world_tool already exist', error.message
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
it 'Tools serializations empty when no tools' do
|
|
106
|
-
assert_empty Rasti::AI::MCP::Server.tools_serializations
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
it 'Call tool executes registered tool' do
|
|
110
|
-
Rasti::AI::MCP::Server.register_tool HelloWorldTool.new
|
|
111
|
-
|
|
112
|
-
result = Rasti::AI::MCP::Server.call_tool 'hello_world_tool'
|
|
113
|
-
|
|
114
|
-
assert_equal '{"text":"Hello world"}', result
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
it 'Call tool not found raises error' do
|
|
118
|
-
error = assert_raises RuntimeError do
|
|
119
|
-
Rasti::AI::MCP::Server.call_tool 'non_existent', {}
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
assert_equal 'Tool non_existent not found', error.message
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
end
|
|
126
|
-
|
|
127
57
|
describe 'Initialize request' do
|
|
128
58
|
|
|
129
59
|
it 'Returns protocol version and capabilities' do
|
|
@@ -148,10 +78,10 @@ describe Rasti::AI::MCP::Server do
|
|
|
148
78
|
|
|
149
79
|
it 'Custom server configuration' do
|
|
150
80
|
Rasti::AI::MCP::Server.configure do |config|
|
|
151
|
-
config.server_name
|
|
81
|
+
config.server_name = 'Custom MCP Server'
|
|
152
82
|
config.server_version = '2.0.0'
|
|
153
83
|
end
|
|
154
|
-
|
|
84
|
+
|
|
155
85
|
post_mcp_request 'initialize'
|
|
156
86
|
|
|
157
87
|
expected_result = {
|
|
@@ -175,19 +105,19 @@ describe Rasti::AI::MCP::Server do
|
|
|
175
105
|
|
|
176
106
|
describe 'Tools list request' do
|
|
177
107
|
|
|
178
|
-
it 'Returns empty list when no
|
|
108
|
+
it 'Returns empty list when no builder configured' do
|
|
179
109
|
post_mcp_request 'tools/list'
|
|
180
110
|
|
|
181
|
-
|
|
182
|
-
tools: []
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
assert_jsonrpc_success expected_result
|
|
111
|
+
assert_jsonrpc_success tools: []
|
|
186
112
|
end
|
|
187
113
|
|
|
188
|
-
it 'Returns registered
|
|
189
|
-
Rasti::AI::MCP::Server.
|
|
190
|
-
|
|
114
|
+
it 'Returns tools registered in builder' do
|
|
115
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
116
|
+
config.load_tools do |tools_registry, _request|
|
|
117
|
+
tools_registry.register tool: HelloWorldTool.new
|
|
118
|
+
tools_registry.register tool: SumTool.new
|
|
119
|
+
end
|
|
120
|
+
end
|
|
191
121
|
|
|
192
122
|
post_mcp_request 'tools/list'
|
|
193
123
|
|
|
@@ -201,13 +131,46 @@ describe Rasti::AI::MCP::Server do
|
|
|
201
131
|
assert_jsonrpc_success expected_result
|
|
202
132
|
end
|
|
203
133
|
|
|
134
|
+
it 'Passes request to builder' do
|
|
135
|
+
received_path = nil
|
|
136
|
+
|
|
137
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
138
|
+
config.load_tools do |tools_registry, request|
|
|
139
|
+
received_path = request.path
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
post_mcp_request 'tools/list'
|
|
144
|
+
|
|
145
|
+
assert_equal '/mcp', received_path
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'Builder runs on every request' do
|
|
149
|
+
call_count = 0
|
|
150
|
+
|
|
151
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
152
|
+
config.load_tools do |tools_registry, _request|
|
|
153
|
+
call_count += 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
post_mcp_request 'tools/list'
|
|
158
|
+
post_mcp_request 'tools/list'
|
|
159
|
+
|
|
160
|
+
assert_equal 2, call_count
|
|
161
|
+
end
|
|
162
|
+
|
|
204
163
|
end
|
|
205
164
|
|
|
206
165
|
describe 'Tools call request' do
|
|
207
166
|
|
|
208
167
|
before do
|
|
209
|
-
Rasti::AI::MCP::Server.
|
|
210
|
-
|
|
168
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
169
|
+
config.load_tools do |tools_registry, _request|
|
|
170
|
+
tools_registry.register tool: HelloWorldTool.new
|
|
171
|
+
tools_registry.register tool: SumTool.new
|
|
172
|
+
end
|
|
173
|
+
end
|
|
211
174
|
end
|
|
212
175
|
|
|
213
176
|
it 'Executes tool with arguments' do
|
|
@@ -234,11 +197,7 @@ describe Rasti::AI::MCP::Server do
|
|
|
234
197
|
end
|
|
235
198
|
|
|
236
199
|
it 'Executes tool without arguments' do
|
|
237
|
-
|
|
238
|
-
name: 'hello_world_tool'
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
post_mcp_request 'tools/call', params
|
|
200
|
+
post_mcp_request 'tools/call', name: 'hello_world_tool'
|
|
242
201
|
|
|
243
202
|
expected_result = {
|
|
244
203
|
content: [
|
|
@@ -252,12 +211,48 @@ describe Rasti::AI::MCP::Server do
|
|
|
252
211
|
assert_jsonrpc_success expected_result
|
|
253
212
|
end
|
|
254
213
|
|
|
255
|
-
it '
|
|
256
|
-
|
|
257
|
-
|
|
214
|
+
it 'Executes block-registered tool' do
|
|
215
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
216
|
+
config.load_tools do |tools_registry, _request|
|
|
217
|
+
schema = {type: 'object', properties: {text: {type: 'string'}}}
|
|
218
|
+
tools_registry.register name: 'echo', description: 'Echo text', input_schema: schema do |args|
|
|
219
|
+
args['text']
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
post_mcp_request 'tools/call', name: 'echo', arguments: {text: 'hello'}
|
|
225
|
+
|
|
226
|
+
expected_result = {
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: 'text',
|
|
230
|
+
text: 'hello'
|
|
231
|
+
}
|
|
232
|
+
]
|
|
258
233
|
}
|
|
259
234
|
|
|
260
|
-
|
|
235
|
+
assert_jsonrpc_success expected_result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it 'Receives request context in builder during tool call' do
|
|
239
|
+
received_header = nil
|
|
240
|
+
|
|
241
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
242
|
+
config.load_tools do |tools_registry, request|
|
|
243
|
+
received_header = request.env['HTTP_X_USER_ID']
|
|
244
|
+
tools_registry.register tool: HelloWorldTool.new
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
header 'X-User-Id', '42'
|
|
249
|
+
post_mcp_request 'tools/call', name: 'hello_world_tool'
|
|
250
|
+
|
|
251
|
+
assert_equal '42', received_header
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it 'Tool call with nonexistent tool returns error' do
|
|
255
|
+
post_mcp_request 'tools/call', name: 'nonexistent'
|
|
261
256
|
|
|
262
257
|
expected_error = {
|
|
263
258
|
code: -32603,
|
|
@@ -268,13 +263,13 @@ describe Rasti::AI::MCP::Server do
|
|
|
268
263
|
end
|
|
269
264
|
|
|
270
265
|
it 'Tool execution error returns error response' do
|
|
271
|
-
Rasti::AI::MCP::Server.
|
|
266
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
267
|
+
config.load_tools do |tools_registry, _request|
|
|
268
|
+
tools_registry.register tool: ErrorTool.new
|
|
269
|
+
end
|
|
270
|
+
end
|
|
272
271
|
|
|
273
|
-
|
|
274
|
-
name: 'error_tool'
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
post_mcp_request 'tools/call', params
|
|
272
|
+
post_mcp_request 'tools/call', name: 'error_tool'
|
|
278
273
|
|
|
279
274
|
expected_error = {
|
|
280
275
|
code: -32603,
|
|
@@ -296,53 +291,117 @@ describe Rasti::AI::MCP::Server do
|
|
|
296
291
|
message: 'Method not found'
|
|
297
292
|
}
|
|
298
293
|
|
|
299
|
-
assert_jsonrpc_error expected_error
|
|
294
|
+
assert_jsonrpc_error expected_error
|
|
300
295
|
end
|
|
301
296
|
|
|
302
|
-
it 'Invalid JSON returns
|
|
303
|
-
post '
|
|
304
|
-
|
|
297
|
+
it 'Invalid JSON returns parse error' do
|
|
298
|
+
post Rasti::AI::MCP::Server.relative_path, 'not valid json', 'CONTENT_TYPE' => 'application/json'
|
|
299
|
+
|
|
305
300
|
assert_equal 400, last_response.status
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
describe 'Authentication' do
|
|
306
|
+
|
|
307
|
+
def assert_unauthorized
|
|
308
|
+
expected_response = {
|
|
309
|
+
jsonrpc: '2.0',
|
|
310
|
+
id: nil,
|
|
311
|
+
error: {
|
|
312
|
+
code: -32002,
|
|
313
|
+
message: 'Unauthorized'
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
assert_equal 401, last_response.status
|
|
306
318
|
assert_equal 'application/json', last_response.content_type
|
|
319
|
+
assert_equal_json JSON.dump(expected_response), last_response.body
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'Allows request when no authenticator configured' do
|
|
323
|
+
post_mcp_request 'initialize'
|
|
307
324
|
|
|
308
|
-
|
|
309
|
-
assert_equal -32700, json_response['error']['code']
|
|
310
|
-
assert_match /unexpected token/, json_response['error']['message']
|
|
325
|
+
assert_equal 200, last_response.status
|
|
311
326
|
end
|
|
312
327
|
|
|
313
|
-
it '
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
it 'Allows request when authenticator returns true' do
|
|
329
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
330
|
+
config.authenticate { |_request| true }
|
|
331
|
+
end
|
|
316
332
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
assert_equal 500, last_response.status
|
|
327
|
-
assert_equal 'application/json', last_response.content_type
|
|
328
|
-
assert_equal_json JSON.dump(expected_response), last_response.body
|
|
333
|
+
post_mcp_request 'initialize'
|
|
334
|
+
|
|
335
|
+
assert_equal 200, last_response.status
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it 'Rejects initialize when authenticator returns false' do
|
|
339
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
340
|
+
config.authenticate { |_request| false }
|
|
329
341
|
end
|
|
342
|
+
|
|
343
|
+
post_mcp_request 'initialize'
|
|
344
|
+
|
|
345
|
+
assert_unauthorized
|
|
330
346
|
end
|
|
331
347
|
|
|
332
|
-
|
|
348
|
+
it 'Rejects tools/list when authenticator returns false' do
|
|
349
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
350
|
+
config.authenticate { |_request| false }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
post_mcp_request 'tools/list'
|
|
354
|
+
|
|
355
|
+
assert_unauthorized
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it 'Rejects tools/call when authenticator returns false' do
|
|
359
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
360
|
+
config.authenticate { |_request| false }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
post_mcp_request 'tools/call', name: 'any_tool'
|
|
364
|
+
|
|
365
|
+
assert_unauthorized
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it 'Validates bearer token from Authorization header' do
|
|
369
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
370
|
+
config.authenticate { |request| request.env['HTTP_AUTHORIZATION'] == 'Bearer secret' }
|
|
371
|
+
config.load_tools { |registry, _| registry.register tool: HelloWorldTool.new }
|
|
372
|
+
end
|
|
333
373
|
|
|
334
|
-
|
|
374
|
+
post_mcp_request 'tools/list'
|
|
375
|
+
assert_unauthorized
|
|
335
376
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
377
|
+
header 'Authorization', 'Bearer secret'
|
|
378
|
+
post_mcp_request 'tools/list'
|
|
339
379
|
assert_equal 200, last_response.status
|
|
340
|
-
assert_equal 'App response', last_response.body
|
|
341
380
|
end
|
|
342
381
|
|
|
382
|
+
it 'Does not call load_tools when authentication fails' do
|
|
383
|
+
tools_loader_called = false
|
|
384
|
+
|
|
385
|
+
Rasti::AI::MCP::Server.configure do |config|
|
|
386
|
+
config.authenticate { |_request| false }
|
|
387
|
+
config.load_tools do |registry, _|
|
|
388
|
+
tools_loader_called = true
|
|
389
|
+
registry.register tool: HelloWorldTool.new
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
post_mcp_request 'tools/list'
|
|
394
|
+
|
|
395
|
+
assert_equal false, tools_loader_called
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
describe 'Request routing' do
|
|
401
|
+
|
|
343
402
|
it 'GET requests to MCP path passed to app' do
|
|
344
403
|
get '/mcp'
|
|
345
|
-
|
|
404
|
+
|
|
346
405
|
assert_equal 200, last_response.status
|
|
347
406
|
assert_equal 'App response', last_response.body
|
|
348
407
|
end
|
|
@@ -361,12 +420,12 @@ describe Rasti::AI::MCP::Server do
|
|
|
361
420
|
|
|
362
421
|
post '/mcp'
|
|
363
422
|
assert_equal 200, last_response.status
|
|
364
|
-
assert_equal 'App response', last_response.body
|
|
365
|
-
|
|
423
|
+
assert_equal 'App response', last_response.body
|
|
424
|
+
|
|
366
425
|
post_mcp_request 'tools/list'
|
|
367
426
|
assert_jsonrpc_success tools: []
|
|
368
427
|
end
|
|
369
428
|
|
|
370
429
|
end
|
|
371
430
|
|
|
372
|
-
end
|
|
431
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
require 'minitest_helper'
|
|
2
|
+
|
|
3
|
+
describe Rasti::AI::MCP::ToolsRegistry do
|
|
4
|
+
|
|
5
|
+
class NoDescriptionTool < Rasti::AI::Tool
|
|
6
|
+
def execute(form)
|
|
7
|
+
{ok: true}
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
let(:tools_registry) { Rasti::AI::MCP::ToolsRegistry.new }
|
|
12
|
+
|
|
13
|
+
describe 'Register with tool instance' do
|
|
14
|
+
|
|
15
|
+
it 'Derives name, description and schema from tool class' do
|
|
16
|
+
tools_registry.register tool: HelloWorldTool.new
|
|
17
|
+
|
|
18
|
+
assert_equal [Rasti::AI::ToolSerializer.serialize(HelloWorldTool)], tools_registry.serializations
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'Overrides name when name is provided' do
|
|
22
|
+
tools_registry.register name: 'custom_name', tool: HelloWorldTool.new
|
|
23
|
+
|
|
24
|
+
assert_equal 'custom_name', tools_registry.serializations.first[:name]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'Overrides description when description is provided' do
|
|
28
|
+
tools_registry.register tool: HelloWorldTool.new, description: 'Custom description'
|
|
29
|
+
|
|
30
|
+
assert_equal 'Custom description', tools_registry.serializations.first[:description]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'Overrides input schema when input_schema is provided' do
|
|
34
|
+
schema = {type: 'object', properties: {x: {type: 'string'}}}
|
|
35
|
+
|
|
36
|
+
tools_registry.register tool: HelloWorldTool.new, input_schema: schema
|
|
37
|
+
|
|
38
|
+
assert_equal schema, tools_registry.serializations.first[:inputSchema]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'Omits description when tool class has none' do
|
|
42
|
+
tools_registry.register tool: NoDescriptionTool.new
|
|
43
|
+
|
|
44
|
+
refute tools_registry.serializations.first.key? :description
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'Executes tool when called' do
|
|
48
|
+
tools_registry.register tool: HelloWorldTool.new
|
|
49
|
+
|
|
50
|
+
result = tools_registry.call 'hello_world_tool'
|
|
51
|
+
|
|
52
|
+
assert_equal '{"text":"Hello world"}', result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'Executes tool with arguments when called' do
|
|
56
|
+
tools_registry.register tool: SumTool.new
|
|
57
|
+
|
|
58
|
+
result = tools_registry.call 'sum_tool', 'number_a' => 3, 'number_b' => 4
|
|
59
|
+
|
|
60
|
+
assert_equal '{"result":7.0}', result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe 'Register with form and block' do
|
|
66
|
+
|
|
67
|
+
it 'Builds schema from form class' do
|
|
68
|
+
tools_registry.register name: 'sum', description: 'Sum two numbers', form: SumTool::Form do |args|
|
|
69
|
+
(args['number_a'] + args['number_b']).to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
expected_serialization = {
|
|
73
|
+
name: 'sum',
|
|
74
|
+
description: 'Sum two numbers',
|
|
75
|
+
inputSchema: Rasti::AI::ToolSerializer.serialize_form(SumTool::Form)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assert_equal [expected_serialization], tools_registry.serializations
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'Executes block when called' do
|
|
82
|
+
tools_registry.register name: 'sum', description: 'Sum', form: SumTool::Form do |args|
|
|
83
|
+
(args['number_a'] + args['number_b']).to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
result = tools_registry.call 'sum', 'number_a' => 1.0, 'number_b' => 2.0
|
|
87
|
+
|
|
88
|
+
assert_equal '3.0', result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe 'Register with input_schema and block' do
|
|
94
|
+
|
|
95
|
+
let(:schema) do
|
|
96
|
+
{
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
query: {type: 'string', description: 'Search query'},
|
|
100
|
+
limit: {type: 'integer'}
|
|
101
|
+
},
|
|
102
|
+
required: ['query']
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'Uses provided input_schema as-is' do
|
|
107
|
+
tools_registry.register name: 'search', description: 'Search content', input_schema: schema do |args|
|
|
108
|
+
"results for #{args['query']}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
expected_serialization = {
|
|
112
|
+
name: 'search',
|
|
113
|
+
description: 'Search content',
|
|
114
|
+
inputSchema: schema
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
assert_equal [expected_serialization], tools_registry.serializations
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'Supports nested schemas' do
|
|
121
|
+
nested_schema = {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
title: {type: 'string'},
|
|
125
|
+
filters: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
category: {type: 'string', enum: ['sales', 'ops']},
|
|
129
|
+
date_range: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
from: {type: 'string', format: 'date'},
|
|
133
|
+
to: {type: 'string', format: 'date'}
|
|
134
|
+
},
|
|
135
|
+
required: ['from', 'to']
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
required: ['title']
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
tools_registry.register name: 'report', description: 'Generate report', input_schema: nested_schema do |args|
|
|
144
|
+
"report: #{args['title']}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
assert_equal nested_schema, tools_registry.serializations.first[:inputSchema]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'Executes block when called' do
|
|
151
|
+
tools_registry.register name: 'search', description: 'Search', input_schema: schema do |args|
|
|
152
|
+
"results for #{args['query']}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
result = tools_registry.call 'search', 'query' => 'ruby'
|
|
156
|
+
|
|
157
|
+
assert_equal 'results for ruby', result
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
describe 'Register combinations' do
|
|
163
|
+
|
|
164
|
+
it 'Registers multiple tools' do
|
|
165
|
+
tools_registry.register tool: HelloWorldTool.new
|
|
166
|
+
tools_registry.register tool: SumTool.new
|
|
167
|
+
|
|
168
|
+
assert_equal 2, tools_registry.serializations.length
|
|
169
|
+
assert_equal 'hello_world_tool', tools_registry.serializations[0][:name]
|
|
170
|
+
assert_equal 'sum_tool', tools_registry.serializations[1][:name]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'input_schema overrides form when both provided' do
|
|
174
|
+
custom_schema = {type: 'object', properties: {x: {type: 'string'}}}
|
|
175
|
+
|
|
176
|
+
tools_registry.register(
|
|
177
|
+
name: 'mixed',
|
|
178
|
+
description: 'Mixed',
|
|
179
|
+
form: SumTool::Form,
|
|
180
|
+
input_schema: custom_schema
|
|
181
|
+
) { |args| args.to_s }
|
|
182
|
+
|
|
183
|
+
assert_equal custom_schema, tools_registry.serializations.first[:inputSchema]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'Block overrides tool executor when both provided' do
|
|
187
|
+
tools_registry.register tool: HelloWorldTool.new do |_args|
|
|
188
|
+
'overridden'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
result = tools_registry.call 'hello_world_tool'
|
|
192
|
+
|
|
193
|
+
assert_equal 'overridden', result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
describe 'Validation' do
|
|
199
|
+
|
|
200
|
+
it 'Raises when name is missing and no tool provided' do
|
|
201
|
+
error = assert_raises ArgumentError do
|
|
202
|
+
tools_registry.register(description: 'No name') { |_| 'ok' }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
assert_equal 'name is required', error.message
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'Raises when no executor provided' do
|
|
209
|
+
error = assert_raises ArgumentError do
|
|
210
|
+
tools_registry.register name: 'no_exec', description: 'No executor'
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
assert_match 'no_exec', error.message
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'Raises when calling unknown tool' do
|
|
217
|
+
error = assert_raises RuntimeError do
|
|
218
|
+
tools_registry.call 'nonexistent'
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
assert_equal 'Tool nonexistent not found', error.message
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
end
|