module RubyLLM::AgUI::Rails::Streaming

The controller concern that serves one AG-UI run as one SSE response.

class AgentRunsController < ApplicationController
  include RubyLLM::AgUI::Rails::Streaming

  def create
    stream_ag_ui_run do |input|
      chat = ag_ui_chats.find_by!(ag_ui_thread_id: input.thread_id)
      authorize! chat
      chat
    end
  end
end

What the host owns, and the concern never guesses:

Refusals are decided before the response is committed, so a client that cannot read an event stream, or that sent an input the gem will not accept, gets a status code instead of half a run.

Constants

CONFLICT

The run cannot be served: a live run already has a stream, or the transport is gone.

NOT_ACCEPTABLE

A client that cannot read an event stream, and tells us so.

NOT_FOUND

‘resolve_chat` found no chat for the thread this actor owns.

Public Instance Methods

ag_ui_scope() click to toggle source

The tenant/actor key every reservation is bounded by.

It must come from the authenticated request, not from the input: a client-chosen scope would let one actor attach to another’s run.

# File lib/ruby_llm/ag_ui/rails/streaming.rb, line 79
def ag_ui_scope
  raise ConfigurationError,
        "#{self.class} must define #ag_ui_scope, built from the authenticated tenant and actor"
end
ag_ui_state() click to toggle source

The state to publish for a reconnect, or nil. The host’s own run state is not the gem’s to guess.

# File lib/ruby_llm/ag_ui/rails/streaming.rb, line 86
def ag_ui_state
  nil
end
stream_ag_ui_run(&resolve_chat) click to toggle source

Streams one run and returns the run’s record, or nil when the client disconnected. The block receives the parsed input and returns the chat for it.

# File lib/ruby_llm/ag_ui/rails/streaming.rb, line 51
def stream_ag_ui_run(&resolve_chat)
  if resolve_chat.nil?
    raise ConfigurationError, 'stream_ag_ui_run needs a block that returns the chat for the input'
  end

  return reject_ag_ui_request(NOT_ACCEPTABLE, accept_message) unless ag_ui_accept?

  input = ag_ui_input
  chat = resolve_chat.call(input)
  run = ag_ui_run(chat, input)

  ag_ui_stream(run)
rescue ::RubyLLM::AgUI::InputError => e
  # Every refusal the gem itself decides carries the status it should be
  # answered with: 400 for an input that cannot be accepted, 409 for an
  # identity claimed with different input or a resume that answers
  # nothing, 413 for a body past the configured bound.
  reject_ag_ui_request(e.http_status, e.message)
rescue ::ActiveRecord::RecordNotFound
  reject_ag_ui_request(NOT_FOUND, 'There is no such thread.')
rescue ::RubyLLM::AgUI::StreamError => e
  reject_ag_ui_request(CONFLICT, e.message)
end