rasti-ai 3.0.1 → 3.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.features/api-normalization/api-design.md +184 -0
  3. data/.features/api-normalization/overview.md +230 -0
  4. data/AGENTS.md +167 -10
  5. data/README.md +161 -23
  6. data/lib/rasti/ai/anthropic/assistant.rb +1 -11
  7. data/lib/rasti/ai/anthropic/client.rb +6 -28
  8. data/lib/rasti/ai/anthropic/provider.rb +87 -0
  9. data/lib/rasti/ai/assistant.rb +10 -11
  10. data/lib/rasti/ai/client.rb +13 -10
  11. data/lib/rasti/ai/errors.rb +8 -0
  12. data/lib/rasti/ai/gemini/assistant.rb +1 -11
  13. data/lib/rasti/ai/gemini/client.rb +2 -24
  14. data/lib/rasti/ai/gemini/provider.rb +90 -0
  15. data/lib/rasti/ai/huawei_maas/assistant.rb +8 -0
  16. data/lib/rasti/ai/huawei_maas/client.rb +8 -0
  17. data/lib/rasti/ai/huawei_maas/provider.rb +25 -0
  18. data/lib/rasti/ai/huawei_maas/roles.rb +7 -0
  19. data/lib/rasti/ai/open_ai/assistant.rb +0 -4
  20. data/lib/rasti/ai/open_ai/client.rb +4 -26
  21. data/lib/rasti/ai/open_ai/provider.rb +74 -0
  22. data/lib/rasti/ai/open_router/assistant.rb +8 -0
  23. data/lib/rasti/ai/open_router/client.rb +8 -0
  24. data/lib/rasti/ai/open_router/provider.rb +25 -0
  25. data/lib/rasti/ai/open_router/roles.rb +7 -0
  26. data/lib/rasti/ai/provider.rb +197 -0
  27. data/lib/rasti/ai/provider_aware.rb +17 -0
  28. data/lib/rasti/ai/result.rb +11 -0
  29. data/lib/rasti/ai/roles.rb +11 -0
  30. data/lib/rasti/ai/version.rb +1 -1
  31. data/lib/rasti/ai.rb +101 -1
  32. data/spec/anthropic/provider_spec.rb +106 -0
  33. data/spec/gemini/provider_spec.rb +90 -0
  34. data/spec/huawei_maas/assistant_spec.rb +80 -0
  35. data/spec/huawei_maas/client_spec.rb +60 -0
  36. data/spec/minitest_helper.rb +6 -0
  37. data/spec/open_ai/provider_spec.rb +101 -0
  38. data/spec/open_router/assistant_spec.rb +80 -0
  39. data/spec/open_router/client_spec.rb +60 -0
  40. data/spec/rasti_ai_spec.rb +322 -0
  41. data/spec/resources/open_ai/conversation_request.json +1 -0
  42. data/spec/resources/open_ai/system_request.json +1 -0
  43. data/tasks/assistant.rake +5 -3
  44. metadata +39 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53a61c4811f7fc87198609ce9311d5208d5e6a3dfb3a616c40e17c2c8bb99e5d
4
- data.tar.gz: 96314ebda147f3cfbf79e2c096fd33783a02d1038b8e233ef45e26121642d54a
3
+ metadata.gz: fa07486ef7216a0a1408d1711ea21ca521283673c06ca2845ad63197dd1dbe8d
4
+ data.tar.gz: dd42552dfd7e3752dac0264b1c89159f04f230c6d067fa674dd4347aae01b0b1
5
5
  SHA512:
6
- metadata.gz: 2265c2a95f49854abef4259cec47b93e1e587f1e9397f547244c94f16d8cb3c4b31feba616275658beed6569267ec6398687d61478698ab396943e7c62e0b5cc
7
- data.tar.gz: d7b8bd65dc9cda869ac6515a05e9eb93c9ff32affbad5a90669d0c0f5e363303ed0322944a06e2b668fa789370b8815b825382884644bb3519c19029a6d171da
6
+ metadata.gz: 1cd0846a2f725823ba691f7d761c5abee299291d9307a7a870cc086f17a02ead88913acaf70cf257221b82a5b8684655595cdc4a8952d4825fa19f23ab7004e5
7
+ data.tar.gz: ea5880cc6e73795cac795189e6a6d192e27fdd11a252768f1b33933b13407d9e1f929e1aaf4020112164dfa880acbe4601708f202795737b434df0f5a63202d6
@@ -0,0 +1,184 @@
1
+ # Diseño de la API pública — `generate_text` / `Agent`
2
+
3
+ Refinamiento de la propuesta de entry points. Conclusión previa: esta API **no es la Opción A**
4
+ (fachada barata). El momento en que `messages:` acepta roles genéricos, el modelo canónico de
5
+ mensajes se vuelve obligatorio — o sea, arrastra la Opción B por construcción. Eso es bueno: evita
6
+ construir A con mensajes provider-specific para tirarlo después.
7
+
8
+ ---
9
+
10
+ ## 1. Firmas propuestas (revisadas)
11
+
12
+ ```ruby
13
+ module Rasti
14
+ module AI
15
+
16
+ def self.generate_text(provider: nil, model: nil,
17
+ prompt: nil, messages: nil, system: nil,
18
+ tools: [], mcp_servers: {}, tool_choice: nil, max_steps: 1,
19
+ json_schema: nil, thinking: nil, max_tokens: nil, temperature: nil,
20
+ api_key: nil, usage_tracker: nil, logger: nil,
21
+ http_connect_timeout: nil, http_read_timeout: nil, http_max_retries: nil,
22
+ provider_options: {})
23
+ end
24
+
25
+ def self.generate_object(schema:, **options) # generate_text + json_schema + result.object
26
+ end
27
+
28
+ end
29
+
30
+ class AI::Agent
31
+
32
+ def initialize(provider: nil, model: nil, state: nil,
33
+ system: nil, tools: [], mcp_servers: {}, tool_choice: nil, max_steps: 20,
34
+ json_schema: nil, thinking: nil, max_tokens: nil, temperature: nil,
35
+ api_key: nil, usage_tracker: nil, logger: nil,
36
+ http_connect_timeout: nil, http_read_timeout: nil, http_max_retries: nil,
37
+ provider_options: {})
38
+ end
39
+
40
+ def call(prompt) # => Result
41
+ end
42
+
43
+ end
44
+ end
45
+ ```
46
+
47
+ `Rasti::AI.create_agent(...)` queda como alias de `Agent.new` si se quiere simetría con
48
+ `generate_text`, pero `Agent.new` es el idioma Ruby y debería ser el camino documentado.
49
+
50
+ ---
51
+
52
+ ## 2. Decisiones y por qué
53
+
54
+ ### 2.1 `prompt:` además de `messages:`
55
+
56
+ El caso mayoritario es un string. Que la firma mínima sea
57
+ `generate_text prompt: 'who is the best player'` vale más que la pureza de un solo parámetro.
58
+ `messages:` queda para control total. Son mutuamente excluyentes (`ArgumentError` si vienen los dos).
59
+
60
+ ### 2.2 `system:` como parámetro, no como mensaje
61
+
62
+ Anthropic y Gemini ponen el system prompt **fuera** del array de mensajes (`system`,
63
+ `system_instruction`). Modelarlo como `{role: :system}` dentro de `messages:` obliga a que cada
64
+ adapter lo extraiga del array. Mejor que sea un campo del `Request`. Igual se acepta un mensaje con
65
+ `role: :system` en `messages:` y se normaliza hacia `system:` al construir el `Request`.
66
+
67
+ Esto además deja claro qué pasa con `state.context`: es el `system` del agente. Sugerencia:
68
+ `Agent.new(system:)` como nombre nuevo, `state.context` sigue funcionando como fuente si no se pasa
69
+ `system:`.
70
+
71
+ ### 2.3 `provider:` acepta Symbol **o** instancia
72
+
73
+ Con `api_key:`, `usage_tracker:`, timeouts y `logger:` como kwargs planos, la firma tiene 8
74
+ parámetros que son de *transporte*, no de generación. Sirve para el caso simple, pero repetirlos en
75
+ cada llamada es ruido. Solución: que `provider:` acepte las dos formas.
76
+
77
+ ```ruby
78
+ # Simple: símbolo, resuelve api_key y defaults de la config global
79
+ Rasti::AI.generate_text provider: :anthropic, model: 'claude-sonnet-4-5', prompt: '...'
80
+
81
+ # Reusable / multi-tenant: instancia con su propia credencial y transporte
82
+ provider = Rasti::AI.provider :anthropic, api_key: tenant.anthropic_key,
83
+ usage_tracker: ->(u) { tenant.track u },
84
+ http_read_timeout: 300
85
+
86
+ Rasti::AI.generate_text provider: provider, model: 'claude-sonnet-4-5', prompt: '...'
87
+ Rasti::AI::Agent.new provider: provider, model: 'claude-sonnet-4-5', tools: tools
88
+ ```
89
+
90
+ Los kwargs planos (`api_key:`, `usage_tracker:`, ...) siguen existiendo como atajo y se usan para
91
+ construir el provider internamente. Un provider explícito los ignora (o lanza `ArgumentError` si se
92
+ mezclan — a definir).
93
+
94
+ Beneficio concreto: `usage_tracker` per-session/per-tenant deja de necesitar un `Client` custom, que
95
+ es la única forma de hacerlo hoy.
96
+
97
+ ### 2.4 `provider_options:` en vez de `**opts`
98
+
99
+ Un `**opts` que se reenvía al body es cómodo pero silencioso: `temparature: 0.5` no falla, se manda y
100
+ el provider lo ignora. Propuesta:
101
+
102
+ - Parámetros **normalizados** explícitos: `thinking`, `max_tokens`, `temperature`, `tool_choice`,
103
+ `max_steps`, `json_schema`. Cada adapter los traduce a su dialecto (como hoy con `thinking`).
104
+ - `provider_options:` — hash que se mergea crudo en el body del provider, escape hatch documentado.
105
+ Equivalente a `providerOptions` de Vercel.
106
+
107
+ Cualquier kwarg desconocido debe explotar. Sin `**opts` en la firma pública.
108
+
109
+ ### 2.5 Valor de retorno: `Result`, no String
110
+
111
+ Hoy `Assistant#call` devuelve un String, y eso obliga a un `Client` custom para conocer el usage y
112
+ hace inaccesible el `finish_reason` y los tool calls ejecutados.
113
+
114
+ ```ruby
115
+ result = Rasti::AI.generate_text provider: :openai, prompt: 'who is the best player'
116
+
117
+ result.text # => 'Lionel Messi'
118
+ result.object # => Hash parseado, cuando hay json_schema (hoy: hay que hacer JSON.parse a mano)
119
+ result.usage # => Usage (o Array de Usage si hubo varios pasos)
120
+ result.messages # => historial canónico completo
121
+ result.tool_calls # => [Part::ToolCall] ejecutados
122
+ result.steps # => [Step] request/response por iteración del loop
123
+ result.finish_reason # => :stop | :tool_calls | :max_steps | :length
124
+ result.raw # => payload crudo del último response
125
+ ```
126
+
127
+ Compatibilidad: `Result#to_s` devuelve `text`. Así `assistant.call('...')` interpolado en un string
128
+ o comparado con `==` sigue andando en la mayoría de los usos existentes. `Result#to_str` también, si
129
+ se quiere coerción implícita completa (a evaluar: puede enmascarar errores).
130
+
131
+ ### 2.6 `json_schema:` acepta Hash **o** `Rasti::Form`
132
+
133
+ `ToolSerializer` ya convierte Forms a JSON Schema. Aceptar un Form cierra el círculo y evita escribir
134
+ schemas a mano. El Hash crudo actual sigue soportado.
135
+
136
+ `generate_object(schema:)` es el azúcar: valida que haya schema y expone `result.object` ya parseado.
137
+
138
+ ### 2.7 `max_steps:`
139
+
140
+ Hoy `Assistant#call` es un `loop do` sin tope: un modelo que insiste en llamar tools cuelga el
141
+ proceso. `max_steps` con `finish_reason: :max_steps` cierra ese agujero. Default: `1` en
142
+ `generate_text` (one-shot explícito, y si hay tools se sube a mano) y `20` en `Agent`.
143
+
144
+ ### 2.8 `state:` en el agente
145
+
146
+ Debe ser opcional (default `AssistantState.new`). Cuando el modelo canónico esté en su lugar,
147
+ `state.messages` es serializable a JSON y un thread se puede persistir y reanudar con otro provider.
148
+ Ese es el entregable que justifica el major.
149
+
150
+ Renombre sugerido: `AssistantState` → `Session` (o `Thread`), con alias de compatibilidad.
151
+
152
+ ### 2.9 Streaming: no hacerlo ahora, no bloquearlo
153
+
154
+ `Provider#decode(raw_body)` sobre la respuesta completa no impide agregar después
155
+ `Provider#decode_stream(chunks)` que emita los mismos `Part` de forma incremental. Basta con no
156
+ asumir en el `Result` que todo llegó de una sola pieza. No entra en este alcance.
157
+
158
+ ---
159
+
160
+ ## 3. Orden de implementación
161
+
162
+ La API pública se define primero (este documento) pero se **expone al final**, sobre base sólida.
163
+
164
+ | Paso | Alcance | Release |
165
+ |---|---|---|
166
+ | 1 | Modelo canónico: `Message`, `Part::*`, `ToolDefinition`, `Request`, `Response`, `Result`, `Step`. Base `Provider` con `encode`/`decode`/`parse_usage`/`endpoint`. Motor `Engine` con el loop (el `call` actual, sin template methods). Port del adapter OpenAI. Los `<Provider>::Assistant` actuales siguen funcionando sobre el motor nuevo vía shim. | interno |
167
+ | 2 | Port de Gemini, Anthropic, OpenRouter, HuaweiMaaS. Se borra: 5 `roles.rb`, `sanitize_schema` duplicado, los `Client` por provider (queda uno solo con auth pluggable), 12 template methods. | interno |
168
+ | 3 | Registry + `Rasti::AI.provider`, `generate_text`, `generate_object`, `Agent`, config `default_provider` / `default_model`. Alias de compatibilidad. README nuevo. | 4.0.0 |
169
+
170
+ Cada paso deja la suite verde. Los fixtures ERB de `spec/resources/` se reutilizan tal cual: pasan a
171
+ testear `encode`/`decode` como funciones puras, sin WebMock, y el loop se testea una sola vez con un
172
+ provider fake.
173
+
174
+ ---
175
+
176
+ ## 4. Puntos abiertos
177
+
178
+ 1. ¿`<Provider>::Client` sobrevive como API pública (shim `chat_completions`/`messages`/
179
+ `generate_content`) o se deprecia en 4.0?
180
+ 2. ¿`Result#to_str` (coerción implícita) o sólo `to_s`? `to_str` maximiza compatibilidad pero puede
181
+ ocultar bugs.
182
+ 3. ¿`Agent` reemplaza a `Assistant` con alias, o conviven documentados?
183
+ 4. Mezclar `provider:` instancia con kwargs de transporte: ¿ignorar en silencio o `ArgumentError`?
184
+ 5. `usage` en el `Result` con varios pasos: ¿array de `Usage` o un `Usage` agregado (+ `#usages`)?
@@ -0,0 +1,230 @@
1
+ # Normalización de la API de Rasti::AI
2
+
3
+ Objetivo: poder elegir provider y modelo **por configuración o por parámetro**, y usar la gema
4
+ para (a) pegarle directo a un modelo y obtener una respuesta, o (b) armar un agente con tools,
5
+ estado y structured output — sin importar el provider. Referencias de diseño: Vercel AI SDK
6
+ (`generateText({ model })`, `LanguageModelV2`) y pi (`agent`, provider/model como strings).
7
+
8
+ ---
9
+
10
+ ## 1. Diagnóstico: qué impide hoy la unificación
11
+
12
+ | Acoplamiento | Dónde | Consecuencia |
13
+ |---|---|---|
14
+ | Los mensajes se guardan con el shape del provider | `AssistantState#messages` + los 5 `build_*_message` | No se puede cambiar de provider en una conversación existente, ni persistir/reanudar una conversación con otro provider |
15
+ | El nombre del método de API difiere | `chat_completions` / `messages` / `generate_content` | El `Assistant` no puede hablarle a un `Client` genérico; `request_completion` es template method |
16
+ | La respuesta se parsea "in situ" | `parse_content`, `parse_tool_calls`, `finished?`, `extract_tool_call_info` | 4 de los 12 template methods son sólo "leer el JSON crudo" |
17
+ | La serialización de tools se re-envuelve por provider | `wrap_tool_serialization`, `extract_tool_name`, `sanitize_schema` duplicado en Gemini y Anthropic | Código copiado; `ToolSerializer` emite `inputSchema` y cada uno lo renombra |
18
+ | Un `Roles` por provider | 5 archivos `roles.rb` | 4 constantes con 3 valores distintos en total (`model`/`function` de Gemini) |
19
+ | La clase elegida ES el provider | `Rasti::AI::OpenAI::Assistant` | La elección de provider es una constante en el código del usuario, no un dato |
20
+
21
+ Números: de ~1.540 líneas de `lib`, los adaptadores de provider son ~700, y buena parte es
22
+ duplicación mecánica (`sanitize_schema` está literalmente dos veces).
23
+
24
+ Observación clave: **el loop de `Assistant#call` ya es universal**. Lo único que cambia por
25
+ provider es la traducción de ida (request) y de vuelta (response). Eso es exactamente la
26
+ frontera que hay que aislar.
27
+
28
+ ---
29
+
30
+ ## 2. Opción A — Fachada + registry (compatible, sin romper nada)
31
+
32
+ Agregar una capa de resolución arriba de lo que ya existe. Cero cambios en las clases actuales.
33
+
34
+ ```ruby
35
+ Rasti::AI.configure do |config|
36
+ config.default_provider = :anthropic
37
+ config.default_model = 'claude-sonnet-4-5'
38
+ end
39
+
40
+ # One-shot, sin agente
41
+ Rasti::AI.generate 'who is the best player'
42
+ Rasti::AI.generate 'who is the best player', model: 'openai:gpt-4o'
43
+
44
+ # Agente, provider como dato
45
+ assistant = Rasti::AI.assistant provider: :gemini, model: 'gemini-2.0-flash', tools: tools
46
+ assistant.call 'what is the weather in Buenos Aires'
47
+ ```
48
+
49
+ Implementación:
50
+
51
+ - `Rasti::AI::Providers` — registry `{openai: OpenAI::Assistant, ...}` + `register`.
52
+ - Parseo de `'provider:model'` (también `'provider/model'`) para resolver ambos de un string.
53
+ - `Rasti::AI.assistant(...)` instancia la clase concreta y devuelve el objeto actual.
54
+ - `Rasti::AI.generate(prompt, **opts)` = assistant efímero + `#call`.
55
+
56
+ | Pros | Contras |
57
+ |---|---|
58
+ | No rompe nada; release 3.2.0 | No elimina una sola línea de duplicación |
59
+ | Muy barato (~1 día con tests) | `state.messages` sigue siendo provider-specific → no se puede migrar una conversación |
60
+ | Habilita ya el "provider por config" | Agregar un provider sigue costando 12 template methods |
61
+ | Es un paso válido *hacia* B (la fachada sobrevive) | La API pública queda con dos formas de hacer lo mismo |
62
+
63
+ Veredicto: útil como **paso 1**, insuficiente como solución.
64
+
65
+ ---
66
+
67
+ ## 3. Opción B — Mensajes canónicos + adaptadores (la normalización real)
68
+
69
+ Un solo `Assistant`/`Agent`. El provider pasa a ser un objeto con dos responsabilidades:
70
+ codificar un request canónico y decodificar una respuesta cruda a un objeto canónico.
71
+
72
+ ### 3.1 Modelo canónico
73
+
74
+ ```ruby
75
+ Rasti::AI::Message # role: :user | :assistant | :tool, parts: [...]
76
+ Rasti::AI::Part::Text # text
77
+ Rasti::AI::Part::ToolCall # id, name, arguments (Hash)
78
+ Rasti::AI::Part::ToolResult # tool_call_id, name, content
79
+ Rasti::AI::Part::Reasoning # text, signature, raw (thinking blocks de Anthropic)
80
+ Rasti::AI::ToolDefinition # name, description, input_schema (JSON Schema neutro)
81
+
82
+ Rasti::AI::Request # messages, system, tools, tool_choice, model, thinking, json_schema, max_tokens
83
+ Rasti::AI::Response # message, finish_reason, usage, raw
84
+ ```
85
+
86
+ `Reasoning` con `raw` es importante: Anthropic exige devolver los thinking blocks intactos en el
87
+ turno siguiente. Guardando el `raw` del part, el encode los reinyecta sin que el modelo canónico
88
+ tenga que entender el formato.
89
+
90
+ ### 3.2 Adaptador
91
+
92
+ ```ruby
93
+ class Rasti::AI::Provider # base abstracta
94
+
95
+ def encode(request) # => Hash (body del provider)
96
+ def decode(raw) # => Rasti::AI::Response
97
+ def parse_usage(raw) # => Usage
98
+ def endpoint(request)
99
+ def client # HTTP genérico, hoy Rasti::AI::Client sin subclases por provider
100
+
101
+ end
102
+ ```
103
+
104
+ Cada provider implementa **4 métodos en vez de 12** (+ auth headers). El `sanitize_schema`
105
+ compartido vive en un `SchemaSanitizer` con la lista de campos permitidos como constante del
106
+ provider. `Roles` desaparece como concepto público: los roles canónicos son símbolos y cada
107
+ adapter tiene su tabla de traducción.
108
+
109
+ ### 3.3 Assistant único
110
+
111
+ ```ruby
112
+ class Rasti::AI::Assistant
113
+
114
+ def initialize(provider: nil, model: nil, tools: [], mcp_servers: {}, json_schema: nil,
115
+ thinking: nil, state: nil, client: nil, logger: nil)
116
+
117
+ def call(prompt) # mismo loop de hoy, sin template methods
118
+
119
+ end
120
+ ```
121
+
122
+ El loop no cambia: `messages << user` → `provider.decode(client.post(provider.endpoint, provider.encode(request)))`
123
+ → si hay `ToolCall` parts, ejecutar y agregar `ToolResult` → si no, devolver el texto.
124
+
125
+ ### 3.4 Lo que se gana
126
+
127
+ - **Portabilidad de conversación**: `state.messages` es canónico y serializable a JSON. Se puede
128
+ persistir un thread, y reanudarlo con otro provider/modelo. Hoy es imposible.
129
+ - **Fallback / routing**: reintentar el mismo request contra otro provider ante rate limit o 5xx.
130
+ - **Un solo lugar** para tool calling, structured output y thinking.
131
+ - **Agregar provider** = 1 archivo, ~4 métodos.
132
+ - **Testing**: se puede testear el loop con un `Provider` fake, sin WebMock, y testear cada
133
+ adapter como función pura `encode/decode` contra los fixtures ERB que ya existen.
134
+
135
+ ### 3.5 Costo y compatibilidad
136
+
137
+ Rotura real y única: el shape de `state.messages`. Todo lo demás se puede preservar:
138
+
139
+ ```ruby
140
+ module Rasti::AI::OpenAI
141
+ class Assistant < Rasti::AI::Assistant
142
+ def initialize(**options)
143
+ super(**options, provider: :open_ai)
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ `Rasti::AI::OpenAI::Client` se puede mantener como shim (`chat_completions` → encode/post) para
150
+ quien lo use directo, o marcarlo deprecado. Los `Roles` se pueden dejar como constantes vivas.
151
+
152
+ Es un **major (4.0.0)** por el cambio de `state.messages`, con las clases por provider intactas
153
+ como alias. Esfuerzo estimado: 3-5 días incluyendo migración de specs.
154
+
155
+ ---
156
+
157
+ ## 4. Opción C — API funcional estilo Vercel/pi encima de B
158
+
159
+ Con B en su lugar, la superficie pública se puede rediseñar para que el uso más común sea una
160
+ línea. Esto es *aditivo* sobre B, no una alternativa.
161
+
162
+ ```ruby
163
+ # Texto
164
+ result = Rasti::AI.generate_text model: 'anthropic:claude-sonnet-4-5',
165
+ system: 'Act as sports journalist',
166
+ prompt: 'who is the best player'
167
+
168
+ result.text # => 'Lionel Messi'
169
+ result.usage # => Usage
170
+ result.messages # => historial canónico
171
+ result.steps # => [Step] con tool calls/results por iteración
172
+ result.finish_reason # => :stop
173
+
174
+ # Objeto estructurado
175
+ result = Rasti::AI.generate_object model: 'openai:gpt-4o',
176
+ schema: PlayerForm, # Rasti::Form → JSON Schema
177
+ prompt: 'who is the best player'
178
+ result.object # => {player: 'Lionel Messi', sport: 'Football'}
179
+
180
+ # Agente reutilizable con estado
181
+ agent = Rasti::AI::Agent.new model: 'gemini:gemini-2.0-flash',
182
+ instructions: 'Act as sports journalist',
183
+ tools: [GetCurrentWeather.new],
184
+ mcp_servers: {weather: mcp_client},
185
+ thinking: 'medium'
186
+
187
+ agent.call 'what is the weather in Buenos Aires'
188
+ agent.call 'and tomorrow?' # mismo thread
189
+ agent.messages # canónico, serializable
190
+ ```
191
+
192
+ Ideas adicionales que este diseño habilita naturalmente:
193
+
194
+ - **`schema:` acepta un `Rasti::Form`** en vez de un Hash a mano — cierra el círculo con
195
+ `ToolSerializer`, que ya sabe convertir Forms a JSON Schema. El `json_schema:` actual (Hash
196
+ crudo) sigue funcionando.
197
+ - **`Rasti::AI.model('openai:gpt-4o')`** como objeto de primera clase, reutilizable e inyectable:
198
+ `Agent.new model: Rasti::AI.model('...', api_key: tenant.key)` → multi-tenant sin globals.
199
+ - **Middleware** (`wrapLanguageModel` de Vercel): un provider decorado para logging, caché de
200
+ respuestas, usage tracking, redacción de PII, o fallback. `usage_tracker` pasa a ser un
201
+ middleware más y deja de estar hardcodeado en el `Client`.
202
+ - **`max_steps:`** para acotar el loop de tools (hoy `loop do` no tiene tope: un modelo que
203
+ insiste en llamar tools puede colgar el proceso indefinidamente — es un bug latente).
204
+ - **Nombres**: `Assistant` → `Agent`, `AssistantState` → `Thread`/`Session`. Alinea con el
205
+ vocabulario de la industria y con lo que la clase realmente es.
206
+
207
+ ---
208
+
209
+ ## 5. Recomendación
210
+
211
+ Camino en dos releases, sin trabajo tirado:
212
+
213
+ 1. **3.2.0 — Opción A.** Registry de providers + `Rasti::AI.assistant(provider:, model:)` +
214
+ `Rasti::AI.generate` + `default_provider`. Compatible. Deja la elección de provider como dato
215
+ y define la superficie pública que va a sobrevivir al refactor.
216
+ 2. **4.0.0 — Opción B + C.** Mensajes canónicos y adaptadores por debajo; `generate_text` /
217
+ `generate_object` / `Agent` por arriba. Las clases `<Provider>::Assistant` quedan como
218
+ subclases de una línea, así que el código existente sigue compilando salvo quien inspeccione
219
+ `state.messages`.
220
+
221
+ Dos puntos a decidir antes de arrancar:
222
+
223
+ - ¿Se mantiene `<Provider>::Client` como API pública (shim) o se deprecia? Afecta cuánto código
224
+ legacy sobrevive en 4.0.
225
+ - ¿`Agent` reemplaza a `Assistant` o convive? Sugerencia: `Agent` como nombre nuevo y
226
+ `Assistant = Agent` como alias, deprecando en 5.0.
227
+
228
+ Restricción a respetar en toda la implementación: **Ruby 2.3**. Sin pattern matching, sin
229
+ `transform_keys`, sin `Hash#slice`. Los objetos canónicos con `Rasti::Model` (que ya es
230
+ dependencia) resuelven la parte de tipado sin agregar nada.