Skip to content

Feat: Ruby SDK - initial implementation, tests, and documentation#1

Closed
aboneto wants to merge 3 commits intomainfrom
feat/ruby-sdk
Closed

Feat: Ruby SDK - initial implementation, tests, and documentation#1
aboneto wants to merge 3 commits intomainfrom
feat/ruby-sdk

Conversation

@aboneto
Copy link
Copy Markdown

@aboneto aboneto commented Dec 22, 2025

This PR introduces the Ruby SDK for the AG‑UI (Agent‑User Interaction) Protocol, including the initial SDK implementation, a test suite, and reference documentation pages.

The goal is to provide Ruby building blocks for:

  • Strongly-typed protocol types
  • Strongly-typed protocol events
  • An SSE event encoder that outputs text/event-stream, serializes keys as camelCase, and omits nil values

What changed:

  • Ruby SDK (ag-ui-protocol gem)
  • Protocol types under AgUiProtocol::Core::Types
  • Protocol events under AgUiProtocol::Core::Events
  • SSE encoder via AgUiProtocol::EventEncoder

Tests:

  • Coverage for JSON serialization, input normalization, and SSE encoding

Docs:

  • New .mdx pages for the Ruby SDK:
    • docs/sdk/ruby/overview.mdx
    • docs/sdk/ruby/core/overview.mdx
    • docs/sdk/ruby/core/types.mdx
    • docs/sdk/ruby/core/events.mdx
    • docs/sdk/ruby/encoder/overview.mdx

@aboneto aboneto self-assigned this Dec 22, 2025
@aboneto aboneto marked this pull request as draft December 22, 2025 14:45
@aboneto aboneto force-pushed the feat/ruby-sdk branch 3 times, most recently from efe557e to 1334710 Compare December 22, 2025 18:26
Copy link
Copy Markdown

@fsateler fsateler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hay varios (aunque no todos) docs de sdk que tienen un overview.mdx general, con instrucciones generales de instalación y uso. Por ejemplo go. Quizas podemos linkear al README de la gema.

Agregar a integrations.mdx el link tambien.


| Parameter | Type | Description |
| --- | --- | --- |
| `accept` | `String` (optional) | Content type accepted by the client |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

La descripción más arriba dice que emite SSE. Si solo emitimos SSE, el accept siempre seria application/json o no?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tu puede responder application/json si no va enviar eventos, responder directo o puede hacerlo por text/event-stream que es por evento.

# @return [String, Object]
sig { params(event: T.untyped).returns(T.untyped) }
def convert_payload_to_sse(event)
if event.respond_to?(:as_json)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Por que aca aceptas as_json, pero en los otros lados usas normalize_value que no? Creo que conviene ser consistente

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize_value es para transformar en un hash a partir del método to_h definido en cada tipo de clase. Ya as_json es para converter en en JSON, pero en el formato esperado por el protocolo, más allá de simplesmente converter en json (This converts keys to camelCase and removes nil values recursively).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entonces si paso un hash no se van a convertir los keys a camelCase... es eso lo esperado?

encoder.encode({ bla_bla: true })
# => "data: {\"bla_bla\": true}\n\n"
encoder.encode(OpenStruct.new(bla_bla: true) 
# => "data: {"blaBla": true}\n\n"

Esto pasará porque Hash responde a as_json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No había visto que Model define as_json. Encuentro raro que la misma transformación la hagamos en 2 partes.

Copy link
Copy Markdown
Author

@aboneto aboneto Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Es verdad, esa parte lo hizo la IA, ahora que veo se quedó redundante si todo hereda de Model. Quité el método y ocupo directamente event.as_json.

acc[k] = compacted
end
when Array
arr = value.map { |v| deep_compact(v) }.reject(&:nil?)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Esto genera 2 arrays, usando reject! eliminas la segunda copia.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eso es para eliminar los valores que quedan en nil para evitar tener propiedades en nil.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sí, a lo que me refiero es que este codigo es equivalente a:

tmp1 = value.map{ |v|  deep_compact(v) }
tmp2 = value.reject(&:nil?)
arr = tmp2

Donde tmp1 y tmp2 son objetos distintos, con su asignación de memoria independiente (tmp1.object_id != tmp2.object_id). Lo que propongo es:

tmp1 = value.map{ |v| deep_compact(v) }
tmp1.reject!(&:nil?)
arr = tmp1

Lo que nos ahorra el generar un array. (ahora que lo releo, podría ser compact! tambien)

Puede que sea una optimización irrelevante, pero no se de que tamaño son los objetos que estarás generando.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cambiado

# @return [String]
sig { params(event: T.untyped).returns(String) }
def encode_sse(event)
payload = convert_payload_to_sse(event)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esto en realidad es convirtiendo a json, no a sse.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nop, es SSE, ese formato "data: #{json_data}\n\n" es lo que se espera en el protocolo.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pero el data: lo agregas despues. No lo haces dentro de convert_payload_to_sse

Copy link
Copy Markdown
Author

@aboneto aboneto Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahora entendí lo que quiere decir, es el nombre del método convert_payload_to_sse . Tan poco es JSON, porque genera después igual, en la misma linea del SSE. Quité el método porque todo viene de Model y siempre van tener as_json. Agregué el tipo también a event para garantizar eso.

description: "Documentation for the events used in the Agent User Interaction Protocol (Ruby SDK)"
---

# Events
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Esto habrá que mantenerlo sincronizado con el codigo, pues es basicamente la docu del modulo Events. No podremos generarlo desde el yard? Idem para los otros docs

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lo voy hacer con yard-markdown.

Copy link
Copy Markdown
Author

@aboneto aboneto Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Listo documentación creada con yard-markdown para mantener siempre actualizada con la documentación del modulo.

Basta ejecutar rake doc que actualiza los markdowns de acuerdo a la documentación de Ruby.

# @return [String]
sig { params(event: T.untyped).returns(String) }
def encode_sse(event)
payload = convert_payload_to_sse(event)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pero el data: lo agregas despues. No lo haces dentro de convert_payload_to_sse

# @return [String, Object]
sig { params(event: T.untyped).returns(T.untyped) }
def convert_payload_to_sse(event)
if event.respond_to?(:as_json)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entonces si paso un hash no se van a convertir los keys a camelCase... es eso lo esperado?

encoder.encode({ bla_bla: true })
# => "data: {\"bla_bla\": true}\n\n"
encoder.encode(OpenStruct.new(bla_bla: true) 
# => "data: {"blaBla": true}\n\n"

Esto pasará porque Hash responde a as_json

acc[k] = compacted
end
when Array
arr = value.map { |v| deep_compact(v) }.reject(&:nil?)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sí, a lo que me refiero es que este codigo es equivalente a:

tmp1 = value.map{ |v|  deep_compact(v) }
tmp2 = value.reject(&:nil?)
arr = tmp2

Donde tmp1 y tmp2 son objetos distintos, con su asignación de memoria independiente (tmp1.object_id != tmp2.object_id). Lo que propongo es:

tmp1 = value.map{ |v| deep_compact(v) }
tmp1.reject!(&:nil?)
arr = tmp1

Lo que nos ahorra el generar un array. (ahora que lo releo, podría ser compact! tambien)

Puede que sea una optimización irrelevante, pero no se de que tamaño son los objetos que estarás generando.

Comment on lines +39 to +44
value.each_with_object({}) do |(k, v), acc|
next if v.nil?
compacted = deep_compact(v)
next if compacted.nil?
acc[k] = compacted
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
value.each_with_object({}) do |(k, v), acc|
next if v.nil?
compacted = deep_compact(v)
next if compacted.nil?
acc[k] = compacted
end
value.transform_values { |v| deep_compact(v) unless v.nil? }.tap(&:compact!)

Mi obsesión por las asignaciones de memoria continúa jaja. (este tiene la ventaja de que no hace crecer el hash lo que puede ser que hagas varias asignaciones, pide una del tamaño máximo que podría tener el hash)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cambiado

require "test_helper"
require "json"

class EventsTest < Minitest::Test
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aca deberian haber tests de que el mensaje se codifica de manera exacta o no? Estas solo validando un atributo por evento (el type)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

He mejorado los tests.

# @return [Hash<Symbol, Object>]
sig { returns(T::Hash[Symbol, T.untyped]) }
def to_h
{}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Como esta es una clase abstracta, debería ser raise NotImplementedError creo yo

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahí lo apliqué.

# @return [String, Object]
sig { params(event: T.untyped).returns(T.untyped) }
def convert_payload_to_sse(event)
if event.respond_to?(:as_json)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No había visto que Model define as_json. Encuentro raro que la misma transformación la hagamos en 2 partes.

{
type: @type,
timestamp: @timestamp,
raw_event: @raw_event
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

porque no retornamos altiro los keys camelCased?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Porque en Ruby normalmente no escribimos así. En la versión de Python igual convierte después, porque quien le gusta así es el front.

@aboneto aboneto force-pushed the feat/ruby-sdk branch 7 times, most recently from 3001f9e to c81f4ad Compare December 23, 2025 20:14
@aboneto
Copy link
Copy Markdown
Author

aboneto commented Dec 23, 2025

Aplicado todos los cambios solicitados.

@aboneto aboneto requested a review from fsateler December 23, 2025 20:18
@aboneto aboneto force-pushed the feat/ruby-sdk branch 12 times, most recently from 92a7532 to 540a5f5 Compare December 29, 2025 14:24
@aboneto aboneto force-pushed the feat/ruby-sdk branch 2 times, most recently from 2e3f4b8 to 570279b Compare January 2, 2026 14:30
@aboneto aboneto closed this Jan 6, 2026
@aboneto aboneto deleted the feat/ruby-sdk branch February 11, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants