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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f40d9534d2254ebcf7efe528f42b7187841ee5eb96ff4b406fcfd6a27a7bdf79
|
|
4
|
+
data.tar.gz: 87a8eb4fbcae1b57ae77c4a9676424b80bb8fbfd8d29e61bb89425c0ed5d5d32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cfc8303f52540e6c6058df5d7a7fe353e5bffd88a6b9122f789066877d0b8a87e7fb5daef1c78db740e54e159c2877dd6ea6540a080eed1a4feb2a1f466326ba
|
|
7
|
+
data.tar.gz: 745f6ea9e9e2a95497514e1330feccebd3a7660709477f43fce3a6dfb03193d8f18f8012a44a976ed574dd4267d4b6979a59c970655e1a6580ef50291ccdd28d
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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', '
|
|
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@
|
|
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:
|
|
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.
|