class RubyLLM::AgUI::Mapper
Projects one RubyLLM 2.x chat turn onto AG-UI 1.0 events (FR-03).
The mapper is the only place that decides which AG-UI event a RubyLLM signal becomes. It is a state machine rather than a set of converters, because the protocol’s rules are about state: a text message must be open before its content arrives, a tool call must be closed before its result, consecutive steps must not overlap, and everything must be closed before the run’s terminal event.
It maps what RubyLLM actually reported and nothing else. It never invents an event to fill a gap in its own output, and it never re-synthesises tool arguments that RubyLLM delivered as a stream: the deltas go out as received, in order.
Every method returns the events to write, in order. The mapper writes nothing itself, so the caller owns the transport and can advance the run record’s sequence with the same list.
mapper = RubyLLM::AgUI::Mapper.new(thread_id: "t1", run_id: "r1") chat.before_tool_call { |tool_call| write(mapper.before_tool_call(tool_call)) } chat.after_message { |message| write(mapper.message(message)) } chat.complete { |chunk| write(mapper.chunk(chunk)) } write(mapper.close) # before the run's terminal event
Constants
- STEP_COMPLETION
The two phases a step delimits: one model round-trip that produces text and/or tool calls, and the tool round that answers those calls. A step ends as soon as a phase of the other kind begins, so consecutive steps never overlap.
- STEP_TOOL
Attributes
Identifies this run. Every id the mapper mints is scoped to it, so two runs on one thread never collide.
The AG-UI conversation this run belongs to.
Public Class Methods
# File lib/ruby_llm/ag_ui/mapper.rb, line 45 def initialize(thread_id:, run_id:, redact: nil, logger: nil) @thread_id = thread_id @run_id = run_id @redact = redact || RubyLLM::AgUI.config.redact @logger = logger || RubyLLM::AgUI.config.logger @message_count = 0 @step_count = 0 @open_text_id = nil @last_message_id = nil @text_emitted = false @calls = {} @open_step = nil @step_kind = nil @warned_redaction = false @closed = false end
Public Instance Methods
A local ToolCall about to execute, from Chat#before_tool_call.
Its arguments are complete, and its deltas have already gone out if the provider streamed them, so this only opens a call the stream never reported and closes the call so the client knows the arguments ended.
# File lib/ruby_llm/ag_ui/mapper.rb, line 90 def before_tool_call(tool_call) return [] if @closed events = tool_call_events({ tool_call.id => tool_call }, streamed: false) events.concat(close_tool_calls) events end
One streamed Chunk from Chat#complete.
# File lib/ruby_llm/ag_ui/mapper.rb, line 63 def chunk(chunk) return [] if @closed events = text_events(chunk.content) events.concat(tool_call_events(chunk.tool_calls, streamed: true)) events.concat(close_events) if chunk.finish_reason events end
Closes any open text message, tool call and step, and closes the mapper.
The caller writes these before the terminal event: AG-UI has no way to close a run whose content is still open, and a client that never sees TEXT_MESSAGE_END waits for a message that will not grow.
Closing is final, and it is the reason a second turn on the same chat is safe. RubyLLM’s callbacks are additive: the callbacks this run registered stay attached after the run ends, so the next ‘complete` on that chat fires this mapper again. A closed mapper maps nothing — the content it would open belongs to a run that is already over, and writing it would report the previous run’s ids and steps inside the next run’s stream.
# File lib/ruby_llm/ag_ui/mapper.rb, line 110 def close @closed = true close_events end
One Message appended to the conversation, from Chat#after_message.
An assistant message ends the completion, so whatever the stream did not carry is emitted here: a provider that did not stream, or a tool call it reported only in the final message, still produces a valid AG-UI sequence instead of a run that silently says nothing.
# File lib/ruby_llm/ag_ui/mapper.rb, line 78 def message(message) return [] if @closed return tool_result_events(message) if message.role == :tool assistant_events(message) end