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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47f94217b69770cc1a2238f28995a4b8cda405554134d0ba7a9dc8a6418080c7
4
- data.tar.gz: fc5522ee0c3ba8bc03f71c50f2c0853be9c2f7c992db7c6a65856ea555b79107
3
+ metadata.gz: f40d9534d2254ebcf7efe528f42b7187841ee5eb96ff4b406fcfd6a27a7bdf79
4
+ data.tar.gz: 87a8eb4fbcae1b57ae77c4a9676424b80bb8fbfd8d29e61bb89425c0ed5d5d32
5
5
  SHA512:
6
- metadata.gz: 5e6ecfcb985c408abc48c12e89ec3b106056d0e8b03958a02fe3a0ee18418a0260cc6a8d736f25e82d15f9c454aa3983b322bafec69122e24149b7d4d90c2f08
7
- data.tar.gz: 966afa955522d3aa598a047abef9457e0dac1e9636c80a3c1df2c0509c8343436ed4d59b585a9587cda9bce96848d6e06adf57550259525cc37f0a92e4470baf
6
+ metadata.gz: cfc8303f52540e6c6058df5d7a7fe353e5bffd88a6b9122f789066877d0b8a87e7fb5daef1c78db740e54e159c2877dd6ea6540a080eed1a4feb2a1f466326ba
7
+ data.tar.gz: 745f6ea9e9e2a95497514e1330feccebd3a7660709477f43fce3a6dfb03193d8f18f8012a44a976ed574dd4267d4b6979a59c970655e1a6580ef50291ccdd28d
@@ -13,32 +13,16 @@ jobs:
13
13
  runs-on: ubuntu-22.04
14
14
  strategy:
15
15
  matrix:
16
- ruby-version: ['2.3', '2.4', '2.5', '2.6', '2.7', '3.0', 'jruby-9.2.9.0']
16
+ ruby-version: ['2.3', '2.4', '2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', 'jruby-9.4']
17
17
 
18
18
  steps:
19
- - uses: actions/checkout@v3
19
+ - uses: actions/checkout@v4
20
20
 
21
21
  - name: Set up Ruby
22
22
  uses: ruby/setup-ruby@v1
23
23
  with:
24
24
  ruby-version: ${{ matrix.ruby-version }}
25
- bundler-cache: false
26
-
27
- - name: Install native dependencies for Ruby 3.0
28
- if: ${{ startsWith(matrix.ruby-version, '3.') }}
29
- run: |
30
- sudo apt-get update
31
- sudo apt-get install -y libcurl4-openssl-dev
32
-
33
- - name: Configure bundler
34
- if: ${{ startsWith(matrix.ruby-version, '3.') }}
35
- run: |
36
- bundle config set --local force_ruby_platform true
37
- bundle install
38
-
39
- - name: Install dependencies for other Ruby versions
40
- if: ${{ !startsWith(matrix.ruby-version, '3.') }}
41
- run: bundle install
25
+ bundler-cache: true
42
26
 
43
27
  - name: Run tests
44
- run: bundle exec rake
28
+ run: bundle exec rake
data/AGENTS.md ADDED
@@ -0,0 +1,614 @@
1
+ # AGENTS.md — Rasti::AI internals
2
+
3
+ Developer and agent reference. For usage examples see README.md.
4
+
5
+ ## Architecture
6
+
7
+ ### Template method pattern
8
+
9
+ `Rasti::AI::Client` and `Rasti::AI::Assistant` are abstract base classes. Provider-specific subclasses implement a fixed set of methods; all shared logic (HTTP retries, tool caching, the request/response loop) lives in the base.
10
+
11
+ #### Client — methods to implement
12
+
13
+ ```ruby
14
+ def default_api_key # reads from Rasti::AI config
15
+ def base_url # e.g. 'https://api.anthropic.com/v1'
16
+ def parse_usage(response) # returns a Usage instance or nil
17
+ # optionally override:
18
+ def build_request(uri) # super + add auth headers
19
+ def build_url(relative_url) # super unless URL needs query params (e.g. Gemini key)
20
+ ```
21
+
22
+ The base `post` method handles JSON serialization, logging, retries on network errors and 5xx, and calls `track_usage` after each successful response.
23
+
24
+ #### Assistant — methods to implement (12 total)
25
+
26
+ ```ruby
27
+ def build_default_client # Client.new
28
+ def build_user_message(prompt) # {role: ..., content: prompt}
29
+ def build_assistant_message(content)
30
+ def build_assistant_tool_calls_message(response)
31
+ def build_tool_result_message(tool_call, name, result)
32
+ def request_completion # calls client's API method
33
+ def parse_tool_calls(response) # returns array; empty = no tool call
34
+ def parse_content(response) # extracts text string
35
+ def finished?(response) # true when model is done
36
+ def extract_tool_call_info(tool_call) # returns [name, args_hash]
37
+ def wrap_tool_serialization(raw) # adapts ToolSerializer output to provider format
38
+ def extract_tool_name(wrapped) # string name from wrapped tool hash
39
+ ```
40
+
41
+ The base `call` loop: add user message → request completion → if tool calls: execute each, add results, loop → else: return content.
42
+
43
+ #### Tool — optional base class
44
+
45
+ Simple tool classes only need a `.form` class method and a `#call(params={})` instance method.
46
+
47
+ Tools inheriting from `Rasti::AI::Tool` define a nested `Form` class and an `execute(form)` method. The base `call` wraps `execute` and JSON-serializes the result — so **`call_tool` in the assistant always receives a String**.
48
+
49
+ Tool names are derived automatically via `Inflecto.underscore(Inflecto.demodulize(class_name))` — e.g. `MyApp::GetWeatherTool` → `get_weather_tool`.
50
+
51
+
52
+ ## Project structure
53
+
54
+ ```
55
+ lib/
56
+ rasti-ai.rb # entry point, just requires rasti/ai
57
+ rasti/
58
+ ai.rb # module definition, config, loads all files
59
+ ai/
60
+ assistant.rb # abstract base class (template method pattern)
61
+ assistant_state.rb # conversation history + tool result cache
62
+ client.rb # abstract HTTP client base
63
+ tool.rb # optional base class for tools
64
+ tool_serializer.rb # converts tool classes to JSON Schema hashes
65
+ usage.rb # value object for token consumption data
66
+ errors.rb # RequestFail, ToolSerializationError, UndefinedTool
67
+ open_ai/
68
+ roles.rb
69
+ client.rb
70
+ assistant.rb
71
+ gemini/
72
+ roles.rb
73
+ client.rb
74
+ assistant.rb
75
+ anthropic/
76
+ roles.rb
77
+ client.rb
78
+ assistant.rb
79
+ mcp/
80
+ server.rb # Rack middleware exposing tools via JSON-RPC 2.0
81
+ tools_registry.rb # per-request tool registry used by the middleware
82
+ client.rb # HTTP client for MCP servers
83
+ errors.rb
84
+
85
+ spec/
86
+ minitest_helper.rb # test config, shared tool class (GoalsByPlayer)
87
+ support/helpers/
88
+ erb.rb # ERB rendering helper for JSON resource templates
89
+ resources.rb # read_resource / read_json_resource helpers
90
+ resources/
91
+ open_ai/ # ERB-templated JSON fixtures
92
+ gemini/
93
+ anthropic/
94
+ open_ai/
95
+ client_spec.rb
96
+ assistant_spec.rb
97
+ gemini/
98
+ client_spec.rb
99
+ assistant_spec.rb
100
+ anthropic/
101
+ client_spec.rb
102
+ assistant_spec.rb
103
+ mcp/
104
+ client_spec.rb
105
+ server_spec.rb
106
+ tools_registry_spec.rb
107
+ tool_serializer_spec.rb
108
+ ```
109
+
110
+
111
+ ## Key runtime dependencies
112
+
113
+ | Gem | Role |
114
+ |---|---|
115
+ | `multi_require` | Auto-requires all files matching a glob pattern (alphabetically sorted) |
116
+ | `rasti-form` | Typed structs used as tool parameter schemas |
117
+ | `rasti-model` | Typed value objects (e.g. `Usage`) |
118
+ | `class_config` | DSL for `attr_config` on modules — provides the `Rasti::AI.configure` block |
119
+ | `inflecto` | String inflection: `underscore` and `demodulize` to derive tool names from class names |
120
+ | `net/http` | HTTP client (stdlib, no extra gem) |
121
+
122
+
123
+ ## Provider API differences
124
+
125
+ | | OpenAI | Gemini | Anthropic |
126
+ |---|---|---|---|
127
+ | Auth | `Authorization: Bearer {key}` | `?key=` query param | `x-api-key: {key}` + `anthropic-version: 2023-06-01` header |
128
+ | Endpoint | `POST /chat/completions` | `POST /models/{model}:generateContent` | `POST /messages` |
129
+ | System prompt | message with `role: system` | top-level `system_instruction` | top-level `system` string |
130
+ | `max_tokens` | optional | optional | **required** (default 4096 in client) |
131
+ | Tool schema key | `parameters` | `parameters` | `input_schema` |
132
+ | Tool choice | `"tool_choice": "auto"` | _(not sent)_ | `"tool_choice": {"type": "auto"}` |
133
+ | Tool result role | `tool` | `function` | `user` (with content block `type: tool_result`) |
134
+ | Tool args in response | JSON string in `function.arguments` | object in `functionCall.args` | object in `input` |
135
+ | Stop signal | `choices[0].finish_reason` | `candidates[0].finishReason` | `stop_reason` |
136
+ | `json_schema` impl | native `response_format` | native `generation_config.response_schema` | forced tool use: adds `structured_output` tool + `tool_choice: {type: tool}` |
137
+
138
+ `ToolSerializer` always outputs `inputSchema`. Each provider renames it in `wrap_tool_serialization`:
139
+ - OpenAI: keeps as-is (passes `inputSchema` directly, API accepts it)
140
+ - Gemini: renames to `parameters`
141
+ - Anthropic: renames to `input_schema`
142
+
143
+
144
+ ## Thinking levels
145
+
146
+ The base `Assistant` accepts `thinking: 'low' | 'medium' | 'high'` (validated on construction; nil = disabled). Each provider translates it in a private `thinking_config` method and passes the result to the client. The client includes it in the request body only if present.
147
+
148
+ | Level | OpenAI `reasoning_effort` | Anthropic `budget_tokens` | Gemini `thinking_budget` |
149
+ |---|---|---|---|
150
+ | `'low'` | `'low'` | `1_024` | `1_024` |
151
+ | `'medium'` | `'medium'` | `8_000` | `8_192` |
152
+ | `'high'` | `'high'` | `16_000` | `24_576` |
153
+
154
+ For Gemini, `thinking_config` goes inside `generation_config` — the client doesn't need a new param. For OpenAI and Anthropic, it's a separate top-level param in the client method (`reasoning_effort:` and `thinking:` respectively).
155
+
156
+ The loop does not change. Anthropic thinking blocks (`type: 'thinking'`) in responses are ignored by `parse_content` (looks for `type == 'text'`) and preserved automatically by `build_assistant_tool_calls_message` (passes full `response['content']` array).
157
+
158
+
159
+ ## Adding a new provider
160
+
161
+ Create three files under `lib/rasti/ai/<provider>/`:
162
+
163
+ 1. **`roles.rb`** — string constants for role names
164
+ 2. **`client.rb`** — inherits `Rasti::AI::Client`, implements the main API method + private helpers
165
+ 3. **`assistant.rb`** — inherits `Rasti::AI::Assistant`, implements all 12 template methods
166
+
167
+ Add to `lib/rasti/ai.rb`:
168
+ ```ruby
169
+ attr_config :<provider>_api_key, ENV['<PROVIDER>_API_KEY']
170
+ attr_config :<provider>_default_model, ENV['<PROVIDER>_DEFAULT_MODEL']
171
+ ```
172
+
173
+ If the new provider supports thinking, define a `THINKING_LEVELS` constant and a private `thinking_config` method (see existing providers). The base constructor already validates and exposes `thinking`.
174
+
175
+ Add an entry to the `PROVIDERS` table in `tasks/assistant.rake` so the interactive task is also available for the new provider:
176
+
177
+ ```ruby
178
+ PROVIDERS = {
179
+ # existing providers ...
180
+ '<provider>' => {key: '<PROVIDER>_API_KEY', klass: -> { Rasti::AI::<Provider>::Assistant }}
181
+ }.freeze
182
+ ```
183
+
184
+ The task name, description, env-key check, logger path and banner are all derived automatically from this entry.
185
+
186
+ Add to `spec/minitest_helper.rb`:
187
+ ```ruby
188
+ config.<provider>_api_key = 'test_<provider>_api_key'
189
+ config.<provider>_default_model = '<provider>-test'
190
+ ```
191
+
192
+ ### ⚠️ multi_require load order
193
+
194
+ `require_relative_pattern 'ai/**/*'` loads files alphabetically. If the new provider name sorts before `assistant` or `client` (e.g. `anthropic` < `assistant`), the subclass is loaded before the base class and raises `NameError`.
195
+
196
+ **Fix already in place**: `lib/rasti/ai.rb` explicitly requires the base classes before the pattern:
197
+
198
+ ```ruby
199
+ require_relative 'ai/errors'
200
+ require_relative 'ai/usage'
201
+ require_relative 'ai/assistant_state'
202
+ require_relative 'ai/tool'
203
+ require_relative 'ai/tool_serializer'
204
+ require_relative 'ai/client'
205
+ require_relative 'ai/assistant'
206
+ require_relative_pattern 'ai/**/*' # duplicates are skipped by Ruby's require
207
+ ```
208
+
209
+ If you add a provider whose name sorts before `client` alphabetically, the same mechanism protects it.
210
+
211
+
212
+ ## Code conventions
213
+
214
+ ### Constants
215
+
216
+ Constants are always defined at the **top of the class body, before `private`**. Never inside the `private` section or between method definitions.
217
+
218
+ ```ruby
219
+ class Client < Rasti::AI::Client
220
+
221
+ ANTHROPIC_VERSION = '2023-06-01'.freeze
222
+ DEFAULT_MAX_TOKENS = 4096
223
+
224
+ private
225
+
226
+ def base_url ...
227
+ end
228
+ ```
229
+
230
+ This also applies to per-provider constants in `Assistant` subclasses (`THINKING_LEVELS`, `ALLOWED_SCHEMA_FIELDS`, etc.).
231
+
232
+ ### Frozen strings
233
+
234
+ All string constants use `.freeze`. Integer and array/hash literals that are already frozen by `%w[]`/`.freeze` on the outer value don't need it again on the inner elements. Integers never need `.freeze`.
235
+
236
+ ```ruby
237
+ USER = 'user'.freeze
238
+ ASSISTANT = 'assistant'.freeze
239
+
240
+ VALID_THINKING_LEVELS = %w[low medium high].freeze
241
+
242
+ THINKING_LEVELS = {
243
+ 'low' => {thinking_budget: 1_024}.freeze,
244
+ 'medium' => {thinking_budget: 8_192}.freeze,
245
+ }.freeze
246
+ ```
247
+
248
+ ### Building request bodies
249
+
250
+ Start with the required keys, then conditionally add optional ones. Never include optional fields as `nil`.
251
+
252
+ ```ruby
253
+ body = {
254
+ model: model || Rasti::AI.anthropic_default_model,
255
+ max_tokens: max_tokens || DEFAULT_MAX_TOKENS,
256
+ messages: messages
257
+ }
258
+
259
+ body[:thinking] = thinking if thinking
260
+ body[:system] = system if system
261
+ body[:tools] = tools unless tools.empty?
262
+ body[:tool_choice] = tool_choice if tool_choice
263
+ ```
264
+
265
+ ### Hash alignment
266
+
267
+ Use `key: value` without padding spaces. Do not align values across keys:
268
+
269
+ ```ruby
270
+ {
271
+ model: model,
272
+ max_tokens: DEFAULT_MAX_TOKENS,
273
+ messages: messages
274
+ }
275
+ ```
276
+
277
+ Nested hashes always go on their own lines, indented one level. Never inline a multi-key hash next to its parent key or inside an array bracket:
278
+
279
+ ```ruby
280
+ # Preferred
281
+ {
282
+ role: Roles::USER,
283
+ content: [
284
+ {
285
+ type: 'tool_result',
286
+ tool_use_id: tool_call['id'],
287
+ content: result
288
+ }
289
+ ]
290
+ }
291
+
292
+ # Avoid
293
+ {
294
+ role: Roles::USER,
295
+ content: [{
296
+ type: 'tool_result',
297
+ tool_use_id: tool_call['id'],
298
+ content: result
299
+ }]
300
+ }
301
+ ```
302
+
303
+ Exception: a single-key nested hash that fits naturally on one line can stay inline (e.g. `THINKING_LEVELS` entries). Apply judgment — the goal is always readability.
304
+
305
+ ### Parentheses
306
+
307
+ Omit parentheses in method calls when they add no clarity — particularly in single-argument calls, `if`/`unless` conditions, and DSL-style invocations:
308
+
309
+ ```ruby
310
+ # Preferred
311
+ raise NotImplementedError
312
+ attr_reader :client
313
+ puts response
314
+
315
+ # Avoid
316
+ raise(NotImplementedError)
317
+ attr_reader(:client)
318
+ puts(response)
319
+ ```
320
+
321
+ Include parentheses when the call uses splat, double-splat, or block arguments (`*`, `**`, `&`), or when omitting them causes ambiguity in a complex expression:
322
+
323
+ ```ruby
324
+ # Required — splat/block args
325
+ object.forward(*args, &block)
326
+
327
+ # Required — disambiguate argument boundary in compound conditions
328
+ if tool && tool.class.respond_to?(:form) # correct
329
+ if tool && tool.class.respond_to? :form # wrong: :form is parsed as arg to &&
330
+
331
+ # Required — call is the value of a hash key or inside an array literal with a constant arg
332
+ # (Ruby 2.3 parser raises SyntaxError: unexpected tCONSTANT)
333
+ { inputSchema: ToolSerializer.serialize_form(SumTool::Form) } # correct
334
+ { inputSchema: ToolSerializer.serialize_form SumTool::Form } # wrong: SyntaxError in Ruby 2.3
335
+ [ToolSerializer.serialize(HelloWorldTool)] # correct
336
+ [ToolSerializer.serialize HelloWorldTool] # wrong: SyntaxError in Ruby 2.3
337
+
338
+ # Required — call is an intermediate argument in a multi-arg call
339
+ # (without parens the outer parser greedily passes subsequent args to the inner call)
340
+ post path, JSON.dump(body), 'CONTENT_TYPE' => 'application/json' # correct
341
+ post path, JSON.dump body, 'CONTENT_TYPE' => 'application/json' # wrong: 'CONTENT_TYPE' => ... is passed to JSON.dump
342
+
343
+ # Fine without parentheses — multiple regular args
344
+ http.post '/path', body: '{}'
345
+ calc.sum 1, 2
346
+ ```
347
+
348
+ ### `private` and `attr_reader`
349
+
350
+ `private` is placed immediately after the class-level constants (if any) and before all method definitions. `attr_reader` always lives inside the `private` section — never in the public interface unless the attribute is intentionally public (e.g. `state`, `model`, `thinking` on `Assistant`).
351
+
352
+ ```ruby
353
+ class Assistant < Rasti::AI::Assistant
354
+
355
+ THINKING_LEVELS = { ... }.freeze
356
+
357
+ private
358
+
359
+ attr_reader :client, :json_schema, :tools, :serialized_tools, :logger
360
+
361
+ def build_default_client ...
362
+ end
363
+ ```
364
+
365
+ ### Instance variables (`@`)
366
+
367
+ Avoid bare `@variable` references in method bodies. Always declare an `attr_reader` inside the `private` section, then access the attribute by its reader name throughout the class. Direct `@` usage is only acceptable inside `initialize` assignments and inside the writer itself.
368
+
369
+ ```ruby
370
+ # Bad — @session_id scattered across methods
371
+ def request_with_session(method, params={})
372
+ raise unless e.message =~ /session/i && @session_id.nil?
373
+ end
374
+
375
+ # Good — declared once, accessed via reader everywhere
376
+ private
377
+
378
+ attr_reader :session_id
379
+
380
+ def request_with_session(method, params={})
381
+ raise unless e.message =~ /session/i && session_id.nil?
382
+ end
383
+
384
+ def initialize_session
385
+ @session_id = response['mcp-session-id'] # @ only for assignment
386
+ end
387
+ ```
388
+
389
+ ### Keyword arguments
390
+
391
+ All method signatures use keyword arguments. Required params have no default; optional params default to `nil`, `[]`, `{}`, or `false` as appropriate.
392
+
393
+ ```ruby
394
+ def messages(messages:, model:nil, system:nil, tools:[], tool_choice:nil, thinking:nil)
395
+ ```
396
+
397
+ ### Template methods
398
+
399
+ Abstract methods in base classes always raise `NotImplementedError` with no message. Do not use `raise NotImplementedError, "override me"` — the bare form is the convention.
400
+
401
+ ```ruby
402
+ def build_default_client
403
+ raise NotImplementedError
404
+ end
405
+ ```
406
+
407
+ ### No unused constants
408
+
409
+ Don't leave constants defined unless they are referenced in the same file. If a constant was added in anticipation of future use, remove it until it's actually needed.
410
+
411
+ ### Ruby compatibility
412
+
413
+ The gem must run on Ruby **2.3 and later**. Do not use language features or stdlib methods introduced after 2.3. Common pitfalls:
414
+
415
+ | Avoid | Use instead |
416
+ |---|---|
417
+ | `hash.transform_keys { \|k\| ... }` | `Hash[hash.map { \|k, v\| [transform(k), v] }]` |
418
+ | `hash.filter { ... }` | `hash.select { ... }` |
419
+ | Numbered block params (`_1`, `_2`) | Named block params (`\|k, v\|`) |
420
+ | Pattern matching (`case/in`) | `case/when` or conditionals |
421
+ | `Array#sum` with initial value | `inject(:+)` or `reduce` |
422
+ | `Hash#slice` | `select` + key check |
423
+
424
+ When in doubt, check the Ruby 2.3 docs or test against the lowest version in the CI matrix.
425
+
426
+
427
+ ## Test conventions
428
+
429
+ ### Framework and libraries
430
+
431
+ - **Minitest** with `describe`/`it` blocks (spec style)
432
+ - **WebMock** for HTTP stubbing — real connections are disabled in all tests
433
+ - **Minitest::Mock** for mock objects
434
+
435
+ ### JSON fixtures with ERB
436
+
437
+ Request/response bodies live in `spec/resources/<provider>/` as ERB-templated JSON files. Use `read_resource` to render them:
438
+
439
+ ```ruby
440
+ read_resource('anthropic/basic_request.json', model: model, prompt: question)
441
+ # => '{"model":"claude-test","max_tokens":4096,...}'
442
+
443
+ read_json_resource('anthropic/basic_response.json', content: answer)
444
+ # => parsed Ruby hash
445
+ ```
446
+
447
+ Template example (`basic_request.json`):
448
+ ```
449
+ {"model":"<%= model %>","max_tokens":4096,"messages":[{"role":"user","content":"<%= prompt %>"}]}
450
+ ```
451
+
452
+ Variables are set via `binding.local_variable_set` so any Ruby expression works inside `<%= %>`.
453
+
454
+ ### Stubbing HTTP requests
455
+
456
+ ```ruby
457
+ stub_request(:post, 'https://api.anthropic.com/v1/messages')
458
+ .with(
459
+ headers: {'x-api-key' => Rasti::AI.anthropic_api_key},
460
+ body: read_resource('anthropic/basic_request.json', model: model, prompt: question)
461
+ )
462
+ .to_return(body: read_resource('anthropic/basic_response.json', content: answer))
463
+ ```
464
+
465
+ Body matching is a string comparison, so JSON key order in the fixture must match exactly what the client sends (`JSON.dump` of a Ruby hash with symbol keys produces alphabetical-ish order based on insertion).
466
+
467
+ ### Testing multi-turn tool flows
468
+
469
+ For tests involving tool calls (where the model calls a tool then continues), use `Minitest::Mock` on the client instead of HTTP stubs — it's simpler to set up multiple sequential responses:
470
+
471
+ ```ruby
472
+ let(:client) { Minitest::Mock.new }
473
+
474
+ client.expect :messages, tool_response do |params|
475
+ params[:messages].last[:role] == 'user' &&
476
+ params[:messages].last[:content] == question
477
+ end
478
+
479
+ client.expect :messages, basic_response(answer) do |params|
480
+ params[:messages].last[:content] == [{type: 'tool_result', ...}]
481
+ end
482
+
483
+ assistant = Rasti::AI::Anthropic::Assistant.new client: client, tools: [tool]
484
+ assistant.call question
485
+ client.verify
486
+ ```
487
+
488
+ The block form of `expect` is used (instead of the positional args form) because keyword argument matching is more reliable with it across Ruby versions.
489
+
490
+ ### Shared test tool
491
+
492
+ `GoalsByPlayer` is defined in `minitest_helper.rb` and used across all provider assistant specs:
493
+
494
+ ```ruby
495
+ class GoalsByPlayer
496
+ def self.form
497
+ Rasti::Form[player: Rasti::Types::String, team: Rasti::Types::String]
498
+ end
499
+
500
+ def call(params={})
501
+ '672'
502
+ end
503
+ end
504
+ ```
505
+
506
+ It's a simple class (not a `Tool` subclass) that returns a plain string — covering the most common tool interface.
507
+
508
+
509
+ ## Development setup
510
+
511
+ - The minimum supported Ruby version is **2.3** (`required_ruby_version >= 2.3` in the gemspec). All code must run on 2.3 and every version up through the CI matrix ceiling. See [Ruby compatibility](#ruby-compatibility) in Code conventions.
512
+ - **Run tests**: `bundle exec rake spec`
513
+ - **Run a single file**: `bundle exec rake spec TEST=spec/anthropic/client_spec.rb`
514
+ - **Run a single test by line**: `bundle exec rake spec TEST=spec/anthropic/client_spec.rb:42`
515
+ - **Run by name**: `bundle exec rake spec NAME=tool`
516
+ - **Console**: `bundle exec rake console` (loads the gem + Pry)
517
+ - **Interactive chat** (requires provider API key in env):
518
+ ```
519
+ rake assistant:openai # OPENAI_API_KEY
520
+ rake assistant:gemini # GEMINI_API_KEY
521
+ rake assistant:anthropic # ANTHROPIC_API_KEY
522
+ ```
523
+ Each task validates the key, writes logs to `log/<provider>.log`, connects to the [Pipeworx](https://pipeworx.io) public weather MCP server, and starts a `You:` / `Assistant:` prompt loop (`exit` or `Ctrl+C` to quit). The model can be overridden with the matching env variable (e.g. `OPENAI_DEFAULT_MODEL=gpt-4o`).
524
+
525
+
526
+ ## ToolSerializer
527
+
528
+ `ToolSerializer.serialize_form(form_class)` delegates to `form_class.to_schema` (`Rasti::Model::Schema.serialize`) and converts the result to JSON Schema via two private methods:
529
+
530
+ - **`json_schema_from_model_schema(schema)`** — takes the full model schema hash (`{model:, attributes: [...]}`) and builds a JSON Schema `{type: 'object', properties: {...}, required: [...]}`. Required attributes are those with `options[:required]` truthy.
531
+ - **`json_schema_for_type(type_hash)`** — maps a single model-schema type hash to its JSON Schema equivalent. Recursive for `:array` (via `items`) and `:model` (delegates back to `json_schema_from_model_schema`).
532
+
533
+ Type mapping:
534
+
535
+ | Model schema type | JSON Schema |
536
+ |---|---|
537
+ | `:string`, `:symbol` | `{type: 'string'}` |
538
+ | `:integer` | `{type: 'integer'}` |
539
+ | `:float` | `{type: 'number'}` |
540
+ | `:boolean` | `{type: 'boolean'}` |
541
+ | `:time` | `{type: 'string', format: 'date'}` |
542
+ | `:enum` | `{type: 'string', enum: [...]}` |
543
+ | `:array` | `{type: 'array', items: ...}` (recursive) |
544
+ | `:model` | `{type: 'object', properties: ...}` (recursive) |
545
+ | `:hash` | `{type: 'object'}` |
546
+ | `:any`, `:unknown`, anything else | `{}` (no constraints, no crash) |
547
+
548
+ ### Extension points
549
+
550
+ Because serialization goes through `Rasti::Model::Schema`, both extension mechanisms it provides work automatically:
551
+
552
+ - **`Rasti::Model::Schema.register_type_serializer(type, serialized_type=nil, &block)`** — registers a mapping for a custom type class. The returned type hash goes through `json_schema_for_type` like any other.
553
+ - **`to_schema` duck-typing** — if the type itself responds to `to_schema`, `Rasti::Model::Schema` calls it and uses the result. No changes needed in `ToolSerializer`.
554
+
555
+ Custom type registrations should live in the application (e.g. an initializer), not in `rasti-ai`.
556
+
557
+ ## MCP Server
558
+
559
+ `Rasti::AI::MCP::Server` is a Rack middleware with a class-level DSL backed by `ClassConfig`. Two independent config blocks drive its behavior:
560
+
561
+ | Config | DSL method | Role |
562
+ |---|---|---|
563
+ | `authenticator` | `authenticate(&block)` | Optional auth check; runs in `handle_mcp_request` before the body is read |
564
+ | `tools_loader` | `load_tools(&block)` | Per-request tool registration; runs inside `handle_mcp_request` after the body is parsed |
565
+
566
+ ### Request flow
567
+
568
+ `call`:
569
+ 1. Path + method check — non-MCP requests pass through to `app` unchanged
570
+ 2. `handle_mcp_request`
571
+
572
+ `handle_mcp_request`:
573
+ 1. Auth check — if `authenticator` is set and returns falsy → return `unauthorized_response` (HTTP 401), stop
574
+ 2. Read and parse body
575
+ 3. `build_tools_registry`, dispatch by `data['method']`
576
+
577
+ ### Auth response format
578
+
579
+ Auth is checked before the body is parsed, so `id` is `nil`. The MCP spec states that HTTP-level errors (e.g. 403 for invalid Origin) MAY include a JSON-RPC error body with no `id`; the same pattern applies to 401:
580
+
581
+ ```
582
+ HTTP/1.1 401 Unauthorized
583
+ Content-Type: application/json
584
+
585
+ {"jsonrpc":"2.0","id":null,"error":{"code":-32002,"message":"Unauthorized"}}
586
+ ```
587
+
588
+ Error code `-32002` maps to `JSON_RPC_SERVER_UNAUTHORIZED` defined in `mcp/constants.rb`.
589
+
590
+ ### Why auth goes inside `handle_mcp_request`
591
+
592
+ `handle_mcp_request` owns the complete request lifecycle: auth, parse, dispatch. Keeping the check there makes the method the single place to read when reasoning about what happens to an incoming MCP request. `call` stays minimal — just routing.
593
+
594
+ The check is inlined, consistent with how other error responses are built in the class:
595
+
596
+ ```ruby
597
+ if !authorized? request
598
+ response = error_response nil, JSON_RPC_SERVER_UNAUTHORIZED, 'Unauthorized'
599
+ return [401, {'Content-Type' => 'application/json'}, [JSON.dump(response)]]
600
+ end
601
+ ```
602
+
603
+ Nota: `authorized?` usa `authenticator.call(request)` con paréntesis. En Ruby 2.3, `proc.call arg` sin paréntesis con un solo argumento genera `SyntaxError` — el parser espera `end` después de `call` y ve el identificador suelto. Con múltiples argumentos (`tools_loader.call tools_registry, request`) funciona porque la coma actúa de separador.
604
+
605
+ ## CI
606
+
607
+ GitHub Actions — `.github/workflows/ci.yml`.
608
+
609
+ - **Matrix**: Ruby `2.3` through `3.3` + `jruby-9.4` (JRuby 9.4 = Ruby 3.1 compat)
610
+ - **`ruby/setup-ruby@v1`** with `bundler-cache: true` — handles `bundle install` and caching automatically, no separate install step needed
611
+ - **No native extensions** — do not add `libcurl4-openssl-dev` or `force_ruby_platform` steps; this gem uses only `net/http` (stdlib)
612
+ - **`required_ruby_version`** is set to `>= 2.3` in the gemspec, consistent with the matrix
613
+
614
+ > If adding Ruby versions beyond 3.3, verify that the dev dependencies (`rake ~> 12.0`, `minitest ~> 5.0, < 5.11`) still install. If they don't, the gemspec constraints will need updating.