Skip to content

Nested Structured Output (ActiveAgent::SchemaGenerator) #260

@skovy

Description

@skovy

There are use cases to have arrays of objects in a JSON schema for structured output, which are supported by OpenAI: https://platform.openai.com/docs/guides/structured-outputs#definitions-are-supported

It appears this works with ActiveRecord, but not with ActiveModel:

when :has_many, :has_and_belongs_to_many
schema[:properties][association.name.to_s] = {
type: "array",
items: { "$ref": "#/$defs/#{association.name.to_s.singularize}" }
}
if options[:nested_associations]
nested_schema = json_schema_from_model(
association.klass,
options.merge(include_associations: false)
)
schema[:$defs][association.name.to_s.singularize] = nested_schema
end

Could we add support for this to ActiveAgent::SchemaGenerator for ActiveModel?

I have a hacky prototype / monkeypatch, but would be great to have upstream support for this.

class Schemas::ApplicationSchema
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveAgent::SchemaGenerator

  # ActiveModel doesn't support associations like ActiveRecord.
  #
  # This adds a simple `has_many` declaration for nested schemas, enabling
  # JSON Schema generation with $defs/$ref for AI agent structured output.
  class << self
    def associations
      @associations ||= {}
    end

    def has_many(name, class_name:)
      klass = class_name.classify.constantize

      associations[name.to_s] = {
        type: :array,
        class: klass
      }

      attribute name, default: -> { [] }
    end

    def to_json_schema(options = {})
      result = super

      if associations.any?
        schema = options[:strict] ? result[:schema] : result
        schema[:$defs] ||= {}

        associations.each do |association_name, config|
          klass = config[:class]
          singular_name = association_name.to_s.singularize

          nested_schema = klass.to_json_schema(strict: options[:strict], name: singular_name)
          schema_to_store = options[:strict] ? nested_schema[:schema] : nested_schema

          schema[:$defs][singular_name] = schema_to_store
          schema[:properties][association_name] = {
            type: "array",
            items: {"$ref": "#/$defs/#{singular_name}"}
          }
        end
      end

      result
    end
  end
end
require "rails_helper"

class Schemas::ExampleSchema < Schemas::ApplicationSchema
  class Address < Schemas::ApplicationSchema
    attribute :street, :string
    attribute :city, :string
    attribute :state, :string
    attribute :zip, :string
  end

  attribute :name, :string
  attribute :age, :integer
  attribute :email, :string

  has_many :addresses, class_name: Address.name

  validates :name, presence: true
  validates :age, presence: true
  validates :email, presence: true
end

RSpec.describe Schemas::ApplicationSchema, type: :model do
  describe "#to_json_schema" do
    let(:strict) { true }

    subject { Schemas::ExampleSchema.to_json_schema(strict:, name: "example_schema") }

    context "when strict" do
      let(:strict) { true }

      it "returns a valid JSON schema" do
        expect(subject).to eq(
          {
            name: "example_schema",
            schema: {
              type: "object",
              properties: {
                "name" => {type: "string"},
                "age" => {type: "integer"},
                "email" => {type: "string"},
                "addresses" => {
                  type: "array",
                  items: {"$ref": "#/$defs/address"}
                }
              },
              required: ["addresses", "age", "email", "name"],
              additionalProperties: false,
              "$defs": {
                "address" => {
                  type: "object",
                  properties: {
                    "street" => {type: "string"},
                    "city" => {type: "string"},
                    "state" => {type: "string"},
                    "zip" => {type: "string"}
                  },
                  required: ["city", "state", "street", "zip"],
                  additionalProperties: false
                }
              }
            },
            strict: true
          }
        )
      end
    end

    context "when not strict" do
      let(:strict) { false }

      it "returns a valid JSON schema" do
        expect(subject).to eq({
          type: "object",
          properties: {
            "name" => {type: "string"},
            "age" => {type: "integer"},
            "email" => {type: "string"},
            "addresses" => {
              type: "array",
              items: {"$ref": "#/$defs/address"}
            }
          },
          required: ["name", "age", "email"],
          additionalProperties: false,
          "$defs": {
            "address" => {
              type: "object",
              properties: {
                "street" => {type: "string"},
                "city" => {type: "string"},
                "state" => {type: "string"},
                "zip" => {type: "string"}
              },
              required: [],
              additionalProperties: false
            }
          }
        })
      end
    end
  end
end

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions