ruby_llm-ag_ui 0.1.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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +48 -0
  3. data/CONTRIBUTING.md +56 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +146 -0
  6. data/SECURITY.md +30 -0
  7. data/docs/_advanced/rails-integration.md +199 -0
  8. data/docs/_advanced/security-and-deployment.md +204 -0
  9. data/docs/_core_features/approval-and-resume.md +62 -0
  10. data/docs/_core_features/input-validation.md +64 -0
  11. data/docs/_core_features/reconnects.md +42 -0
  12. data/docs/_core_features/streaming-runs.md +49 -0
  13. data/docs/_getting_started/configuration.md +57 -0
  14. data/docs/_getting_started/getting-started.md +52 -0
  15. data/docs/_getting_started/overview.md +189 -0
  16. data/docs/_getting_started/whats-new-in-0-1.md +41 -0
  17. data/docs/_reference/api-reference.md +26 -0
  18. data/docs/_reference/compatibility.md +130 -0
  19. data/docs/_reference/protocol-coverage.md +175 -0
  20. data/docs/_reference/release-history.md +22 -0
  21. data/docs/_reference/releasing.md +54 -0
  22. data/docs/_reference/third-party-notices.md +82 -0
  23. data/docs/_reference/upgrading.md +42 -0
  24. data/docs/index.md +53 -0
  25. data/lib/generators/ruby_llm/ag_ui/install_generator.rb +37 -0
  26. data/lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt +24 -0
  27. data/lib/generators/ruby_llm/ag_ui/templates/initializer.rb.tt +24 -0
  28. data/lib/ruby_llm/ag_ui/canonical.rb +40 -0
  29. data/lib/ruby_llm/ag_ui/configuration.rb +113 -0
  30. data/lib/ruby_llm/ag_ui/coverage.rb +163 -0
  31. data/lib/ruby_llm/ag_ui/emitter.rb +119 -0
  32. data/lib/ruby_llm/ag_ui/error.rb +69 -0
  33. data/lib/ruby_llm/ag_ui/error_sanitizer.rb +69 -0
  34. data/lib/ruby_llm/ag_ui/input.rb +299 -0
  35. data/lib/ruby_llm/ag_ui/interrupt.rb +207 -0
  36. data/lib/ruby_llm/ag_ui/mapper.rb +333 -0
  37. data/lib/ruby_llm/ag_ui/message_parser.rb +127 -0
  38. data/lib/ruby_llm/ag_ui/protocol.rb +130 -0
  39. data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +119 -0
  40. data/lib/ruby_llm/ag_ui/rails/railtie.rb +29 -0
  41. data/lib/ruby_llm/ag_ui/rails/record.rb +51 -0
  42. data/lib/ruby_llm/ag_ui/rails/streaming.rb +166 -0
  43. data/lib/ruby_llm/ag_ui/reconnect.rb +86 -0
  44. data/lib/ruby_llm/ag_ui/redaction.rb +58 -0
  45. data/lib/ruby_llm/ag_ui/redemption.rb +62 -0
  46. data/lib/ruby_llm/ag_ui/reservation.rb +56 -0
  47. data/lib/ruby_llm/ag_ui/resume.rb +108 -0
  48. data/lib/ruby_llm/ag_ui/resume_entry.rb +49 -0
  49. data/lib/ruby_llm/ag_ui/run.rb +177 -0
  50. data/lib/ruby_llm/ag_ui/run_record.rb +229 -0
  51. data/lib/ruby_llm/ag_ui/sse_writer.rb +119 -0
  52. data/lib/ruby_llm/ag_ui/store/base.rb +123 -0
  53. data/lib/ruby_llm/ag_ui/store/memory_store.rb +123 -0
  54. data/lib/ruby_llm/ag_ui/validation.rb +63 -0
  55. data/lib/ruby_llm/ag_ui/version.rb +8 -0
  56. data/lib/ruby_llm/ag_ui.rb +65 -0
  57. metadata +223 -0
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # One approval request attached to a reserved run, and the answer to it.
6
+ #
7
+ # A RubyLLM `requires_approval` pause becomes one interrupt per pending tool
8
+ # call. The interrupt is the only thing that ties a client resume entry to a
9
+ # tool call, and it is deliberately narrow:
10
+ #
11
+ # * it names the exact `toolCallId` and the digest of the arguments RubyLLM
12
+ # asked to run, so a resume that tries to change the arguments cannot pass
13
+ # as the same request;
14
+ # * it carries a host-owned expiry, so an old approval does not stay valid
15
+ # forever;
16
+ # * it records exactly one decision, so a replayed resume entry gets the
17
+ # stored decision back instead of running the tool a second time.
18
+ #
19
+ # An interrupt is not a permission grant. The host re-authorizes the actor
20
+ # before the tool runs, whatever this record says.
21
+ class Interrupt
22
+ # No decision yet.
23
+ PENDING = :pending
24
+ # The actor approved the tool call.
25
+ APPROVED = :approved
26
+ # The actor declined the tool call.
27
+ DENIED = :denied
28
+ # The actor abandoned the interrupt.
29
+ CANCELLED = :cancelled
30
+
31
+ STATUSES = [PENDING, APPROVED, DENIED, CANCELLED].freeze
32
+
33
+ # The statuses that count as an answer.
34
+ DECISIONS = [APPROVED, DENIED, CANCELLED].freeze
35
+
36
+ # The protocol's reason for a tool approval.
37
+ REASON_TOOL_CALL = 'tool_call_confirmation'
38
+
39
+ # An id unique among the interrupts of one thread. A resume entry answers
40
+ # an interrupt by this id, so it must be unguessable enough that a client
41
+ # cannot answer someone else's approval.
42
+ attr_reader :id
43
+
44
+ # The RubyLLM tool call this interrupt concerns.
45
+ attr_reader :tool_call_id
46
+
47
+ # Why the run stopped. An open protocol string.
48
+ attr_reader :reason
49
+
50
+ # A human-readable prompt for whoever answers.
51
+ attr_reader :message
52
+
53
+ # The SHA-256 digest of the arguments RubyLLM asked to run. Compared
54
+ # before execution so changed arguments cannot reuse an approval.
55
+ attr_reader :arguments_digest
56
+
57
+ # When the interrupt stops being answerable.
58
+ attr_reader :expires_at
59
+
60
+ # :pending, :approved, :denied or :cancelled.
61
+ attr_reader :status
62
+
63
+ # When the decision was recorded, or nil while pending.
64
+ attr_reader :decided_at
65
+
66
+ # When the interrupt was created.
67
+ attr_reader :created_at
68
+
69
+ # rubocop:disable Metrics/ParameterLists -- one keyword per field the value object owns
70
+ def initialize(id:, tool_call_id:, arguments_digest:, expires_at:, reason: REASON_TOOL_CALL,
71
+ message: nil, status: PENDING, decided_at: nil, created_at: nil)
72
+ @id = id
73
+ @tool_call_id = tool_call_id
74
+ @arguments_digest = arguments_digest
75
+ @expires_at = expires_at
76
+ @reason = reason
77
+ @message = message
78
+ @status = assert_status!(status)
79
+ @decided_at = decided_at
80
+ @created_at = created_at
81
+ freeze
82
+ end
83
+
84
+ # Builds an interrupt with an unguessable id.
85
+ def self.create(tool_call_id:, arguments_digest:, expires_at:, reason: REASON_TOOL_CALL, message: nil,
86
+ created_at: nil)
87
+ new(
88
+ id: SecureRandom.uuid, tool_call_id: tool_call_id, arguments_digest: arguments_digest,
89
+ expires_at: expires_at, reason: reason, message: message, created_at: created_at
90
+ )
91
+ end
92
+ # rubocop:enable Metrics/ParameterLists
93
+
94
+ # Whether the interrupt still waits for an answer.
95
+ def pending?
96
+ status == PENDING
97
+ end
98
+
99
+ # Whether a decision has been recorded.
100
+ def decided?
101
+ DECISIONS.include?(status)
102
+ end
103
+
104
+ # Whether the actor approved the call.
105
+ def approved?
106
+ status == APPROVED
107
+ end
108
+
109
+ # Whether the actor declined the call.
110
+ def denied?
111
+ status == DENIED
112
+ end
113
+
114
+ # Whether the actor abandoned the interrupt.
115
+ def cancelled?
116
+ status == CANCELLED
117
+ end
118
+
119
+ # Whether the host-owned expiry has passed at +now+.
120
+ def expired?(now)
121
+ !expires_at.nil? && now >= expires_at
122
+ end
123
+
124
+ # Whether the interrupt can still be answered at +now+.
125
+ def answerable?(now)
126
+ pending? && !expired?(now)
127
+ end
128
+
129
+ # Returns a copy carrying +decision+. Refuses to overwrite an existing
130
+ # decision: a second answer is a replay or a conflict, never a new grant.
131
+ def decide(decision, now:)
132
+ raise ConfigurationError, "decision must be one of #{DECISIONS.join(', ')}" unless DECISIONS.include?(decision)
133
+ raise StreamError, "interrupt #{id} was already #{status}" if decided?
134
+
135
+ self.class.new(
136
+ id: id, tool_call_id: tool_call_id, arguments_digest: arguments_digest, expires_at: expires_at,
137
+ reason: reason, message: message, status: decision, decided_at: now, created_at: created_at
138
+ )
139
+ end
140
+
141
+ # The protocol's Interrupt shape, ready for a RUN_FINISHED interrupt
142
+ # outcome. Only the fields the pinned schema defines are set.
143
+ def to_protocol
144
+ ::AgUiProtocol::Core::Types::Interrupt.new(
145
+ id: id,
146
+ reason: reason,
147
+ message: message,
148
+ tool_call_id: tool_call_id,
149
+ expires_at: expires_at&.utc&.iso8601
150
+ )
151
+ end
152
+
153
+ # The interrupt as plain data, with camelCase keys. The arguments digest
154
+ # and the caller-supplied ids stay out of it: they are host bookkeeping.
155
+ def to_h
156
+ {
157
+ 'id' => id,
158
+ 'toolCallId' => tool_call_id,
159
+ 'reason' => reason,
160
+ 'message' => message,
161
+ 'expiresAt' => iso8601(expires_at),
162
+ 'status' => status.to_s,
163
+ 'decidedAt' => iso8601(decided_at)
164
+ }
165
+ end
166
+
167
+ # The interrupt in the form a durable store keeps, which #to_h is not: a
168
+ # store that dropped the arguments digest could not re-verify that a
169
+ # resume answers the call it approved, and one that dropped the creation
170
+ # time could not order two interrupts on a thread.
171
+ def to_storage
172
+ to_h.merge(
173
+ 'argumentsDigest' => arguments_digest,
174
+ 'createdAt' => iso8601(created_at)
175
+ )
176
+ end
177
+
178
+ # Rebuilds an interrupt stored by #to_storage.
179
+ def self.from_storage(hash)
180
+ new(
181
+ id: hash.fetch('id'), tool_call_id: hash.fetch('toolCallId'),
182
+ arguments_digest: hash.fetch('argumentsDigest'), reason: hash.fetch('reason'),
183
+ message: hash['message'], expires_at: parse_time(hash['expiresAt']),
184
+ status: hash.fetch('status').to_sym, decided_at: parse_time(hash['decidedAt']),
185
+ created_at: parse_time(hash['createdAt'])
186
+ )
187
+ end
188
+
189
+ # The time +value+ names, or nil. Storage writes ISO 8601 strings.
190
+ def self.parse_time(value)
191
+ value.nil? ? nil : Time.iso8601(value)
192
+ end
193
+
194
+ private
195
+
196
+ def iso8601(time)
197
+ time&.utc&.iso8601
198
+ end
199
+
200
+ def assert_status!(value)
201
+ return value if STATUSES.include?(value)
202
+
203
+ raise ConfigurationError, "status must be one of #{STATUSES.join(', ')}"
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Projects one RubyLLM 2.x chat turn onto AG-UI 1.0 events (FR-03).
6
+ #
7
+ # The mapper is the only place that decides which AG-UI event a RubyLLM
8
+ # signal becomes. It is a state machine rather than a set of converters,
9
+ # because the protocol's rules are about state: a text message must be open
10
+ # before its content arrives, a tool call must be closed before its result,
11
+ # consecutive steps must not overlap, and everything must be closed before
12
+ # the run's terminal event.
13
+ #
14
+ # It maps what RubyLLM actually reported and nothing else. It never invents
15
+ # an event to fill a gap in its own output, and it never re-synthesises tool
16
+ # arguments that RubyLLM delivered as a stream: the deltas go out as
17
+ # received, in order.
18
+ #
19
+ # Every method returns the events to write, in order. The mapper writes
20
+ # nothing itself, so the caller owns the transport and can advance the run
21
+ # record's sequence with the same list.
22
+ #
23
+ # mapper = RubyLLM::AgUI::Mapper.new(thread_id: "t1", run_id: "r1")
24
+ #
25
+ # chat.before_tool_call { |tool_call| write(mapper.before_tool_call(tool_call)) }
26
+ # chat.after_message { |message| write(mapper.message(message)) }
27
+ # chat.complete { |chunk| write(mapper.chunk(chunk)) }
28
+ #
29
+ # write(mapper.close) # before the run's terminal event
30
+ class Mapper
31
+ # The two phases a step delimits: one model round-trip that produces text
32
+ # and/or tool calls, and the tool round that answers those calls. A step
33
+ # ends as soon as a phase of the other kind begins, so consecutive steps
34
+ # never overlap.
35
+ STEP_COMPLETION = :completion
36
+ STEP_TOOL = :tool
37
+
38
+ # The AG-UI conversation this run belongs to.
39
+ attr_reader :thread_id
40
+
41
+ # Identifies this run. Every id the mapper mints is scoped to it, so two
42
+ # runs on one thread never collide.
43
+ attr_reader :run_id
44
+
45
+ def initialize(thread_id:, run_id:, redact: nil, logger: nil)
46
+ @thread_id = thread_id
47
+ @run_id = run_id
48
+ @redact = redact || RubyLLM::AgUI.config.redact
49
+ @logger = logger || RubyLLM::AgUI.config.logger
50
+ @message_count = 0
51
+ @step_count = 0
52
+ @open_text_id = nil
53
+ @last_message_id = nil
54
+ @text_emitted = false
55
+ @calls = {}
56
+ @open_step = nil
57
+ @step_kind = nil
58
+ @warned_redaction = false
59
+ @closed = false
60
+ end
61
+
62
+ # One streamed Chunk from Chat#complete.
63
+ def chunk(chunk)
64
+ return [] if @closed
65
+
66
+ events = text_events(chunk.content)
67
+ events.concat(tool_call_events(chunk.tool_calls, streamed: true))
68
+ events.concat(close_events) if chunk.finish_reason
69
+ events
70
+ end
71
+
72
+ # One Message appended to the conversation, from Chat#after_message.
73
+ #
74
+ # An assistant message ends the completion, so whatever the stream did not
75
+ # carry is emitted here: a provider that did not stream, or a tool call it
76
+ # reported only in the final message, still produces a valid AG-UI
77
+ # sequence instead of a run that silently says nothing.
78
+ def message(message)
79
+ return [] if @closed
80
+ return tool_result_events(message) if message.role == :tool
81
+
82
+ assistant_events(message)
83
+ end
84
+
85
+ # A local ToolCall about to execute, from Chat#before_tool_call.
86
+ #
87
+ # Its arguments are complete, and its deltas have already gone out if the
88
+ # provider streamed them, so this only opens a call the stream never
89
+ # reported and closes the call so the client knows the arguments ended.
90
+ def before_tool_call(tool_call)
91
+ return [] if @closed
92
+
93
+ events = tool_call_events({ tool_call.id => tool_call }, streamed: false)
94
+ events.concat(close_tool_calls)
95
+ events
96
+ end
97
+
98
+ # Closes any open text message, tool call and step, and closes the mapper.
99
+ #
100
+ # The caller writes these before the terminal event: AG-UI has no way to
101
+ # close a run whose content is still open, and a client that never sees
102
+ # TEXT_MESSAGE_END waits for a message that will not grow.
103
+ #
104
+ # Closing is final, and it is the reason a second turn on the same chat is
105
+ # safe. RubyLLM's callbacks are additive: the callbacks this run registered
106
+ # stay attached after the run ends, so the next `complete` on that chat
107
+ # fires this mapper again. A closed mapper maps nothing — the content it
108
+ # would open belongs to a run that is already over, and writing it would
109
+ # report the previous run's ids and steps inside the next run's stream.
110
+ def close
111
+ @closed = true
112
+ close_events
113
+ end
114
+
115
+ private
116
+
117
+ # --- text ---------------------------------------------------------------
118
+
119
+ def text_events(content)
120
+ return [] if content.nil? || content.empty?
121
+
122
+ events = open_text_events
123
+ events << ::AgUiProtocol::Core::Events::TextMessageContentEvent.new(
124
+ message_id: @open_text_id, delta: content
125
+ )
126
+ @text_emitted = true
127
+ events
128
+ end
129
+
130
+ def open_text_events
131
+ return [] if @open_text_id
132
+
133
+ @open_text_id = new_message_id('a')
134
+ @last_message_id = @open_text_id
135
+ events = ensure_step(STEP_COMPLETION)
136
+ events << ::AgUiProtocol::Core::Events::TextMessageStartEvent.new(
137
+ message_id: @open_text_id, role: 'assistant'
138
+ )
139
+ events
140
+ end
141
+
142
+ def close_text_events
143
+ return [] unless @open_text_id
144
+
145
+ message_id = @open_text_id
146
+ @open_text_id = nil
147
+ [::AgUiProtocol::Core::Events::TextMessageEndEvent.new(message_id: message_id)]
148
+ end
149
+
150
+ # --- assistant message ---------------------------------------------------
151
+
152
+ def assistant_events(message)
153
+ events = []
154
+ events.concat(text_events(message.content)) unless @text_emitted
155
+ events.concat(close_text_events)
156
+ events.concat(tool_call_events(message.tool_calls, streamed: false))
157
+ events.concat(close_tool_calls)
158
+ events.concat(close_step)
159
+ @last_message_id = nil
160
+ @text_emitted = false
161
+ events
162
+ end
163
+
164
+ # --- tool calls ----------------------------------------------------------
165
+
166
+ def tool_call_events(tool_calls, streamed:)
167
+ return [] if tool_calls.nil? || tool_calls.empty?
168
+
169
+ tool_calls.flat_map do |key, tool_call|
170
+ record = track(key, tool_call, streamed: streamed)
171
+ open_tool_call(record) + argument_events(record, tool_call, streamed: streamed)
172
+ end
173
+ end
174
+
175
+ # The record for one tool call, created on first sight.
176
+ #
177
+ # A streamed delta is keyed by index and carries the id only on its first
178
+ # fragment; the complete message is keyed by id. Both keys point at the
179
+ # same record, so the call is opened once.
180
+ def track(key, tool_call, streamed:)
181
+ record = @calls[key] || find_call(tool_call.id) || new_call
182
+ record[:id] ||= tool_call.id
183
+ record[:name] ||= tool_call.name
184
+ record[:streamed] ||= streamed
185
+ @calls[key] = record
186
+ @calls[record[:id]] = record if record[:id]
187
+ record
188
+ end
189
+
190
+ def new_call
191
+ { id: nil, name: nil, streamed: false, args_emitted: false, started: false, closed: false, buffered: nil }
192
+ end
193
+
194
+ def find_call(id)
195
+ id && @calls[id]
196
+ end
197
+
198
+ # Opens the call the first time its id and name are both known. A call
199
+ # reported as arguments-only deltas stays buffered until its name arrives,
200
+ # so no fragment of the provider's argument bytes is dropped.
201
+ def open_tool_call(record)
202
+ return [] if record[:started] || record[:id].nil? || record[:name].nil?
203
+
204
+ record[:started] = true
205
+ events = close_text_events
206
+ events.concat(ensure_step(STEP_COMPLETION))
207
+ events << ::AgUiProtocol::Core::Events::ToolCallStartEvent.new(
208
+ tool_call_id: record[:id], tool_call_name: record[:name], parent_message_id: @last_message_id
209
+ )
210
+ events.concat(buffered_argument_events(record))
211
+ end
212
+
213
+ def buffered_argument_events(record)
214
+ buffered = record[:buffered]
215
+ record[:buffered] = nil
216
+ return [] if buffered.nil? || buffered.empty?
217
+
218
+ [::AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: record[:id], delta: buffered)]
219
+ end
220
+
221
+ def argument_events(record, tool_call, streamed:)
222
+ delta = argument_delta(record, tool_call, streamed: streamed)
223
+ return [] if delta.nil? || delta.empty?
224
+ return buffer_argument(record, delta) unless record[:started]
225
+
226
+ [::AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: record[:id], delta: delta)]
227
+ end
228
+
229
+ def buffer_argument(record, delta)
230
+ record[:buffered] = (record[:buffered] || +'') + delta
231
+ []
232
+ end
233
+
234
+ # A streamed chunk carries a fragment of the provider's own argument bytes
235
+ # and goes out unchanged. A complete message carries the Hash RubyLLM
236
+ # reconstructed from those bytes, so it is serialized once and only when
237
+ # the stream did not already deliver the fragments.
238
+ def argument_delta(record, tool_call, streamed:)
239
+ return streamed_delta(tool_call) if streamed
240
+ return nil if record[:streamed] || record[:args_emitted]
241
+
242
+ record[:args_emitted] = true
243
+ arguments = tool_call.arguments
244
+ arguments.is_a?(String) ? arguments : Canonical.dump(arguments)
245
+ end
246
+
247
+ def streamed_delta(tool_call)
248
+ arguments = tool_call.arguments
249
+ arguments.is_a?(String) ? arguments : arguments.to_s
250
+ end
251
+
252
+ def close_tool_calls
253
+ @calls.values.uniq.filter_map do |record|
254
+ next if record[:closed] || !record[:started]
255
+
256
+ record[:closed] = true
257
+ ::AgUiProtocol::Core::Events::ToolCallEndEvent.new(tool_call_id: record[:id])
258
+ end
259
+ end
260
+
261
+ # --- tool results --------------------------------------------------------
262
+
263
+ def tool_result_events(message)
264
+ events = close_text_events
265
+ events.concat(close_tool_calls)
266
+ events.concat(ensure_step(STEP_TOOL))
267
+ events << ::AgUiProtocol::Core::Events::ToolCallResultEvent.new(
268
+ message_id: "#{@run_id}:tool:#{message.tool_call_id}",
269
+ tool_call_id: message.tool_call_id,
270
+ content: redact(Redaction::TOOL_RESULT, message.content.to_s),
271
+ role: 'tool'
272
+ )
273
+ @text_emitted = false
274
+ events
275
+ end
276
+
277
+ # --- steps ---------------------------------------------------------------
278
+
279
+ # Ensures the step for +kind+ is the open one, closing the previous step
280
+ # first when the phase changed. Opening a step of the same kind is a no-op,
281
+ # so the deltas of one completion stay inside a single step.
282
+ def ensure_step(kind)
283
+ return [] if @open_step && @step_kind == kind
284
+
285
+ events = close_step
286
+ @step_count += 1
287
+ @open_step = "step-#{@step_count}"
288
+ @step_kind = kind
289
+ events << ::AgUiProtocol::Core::Events::StepStartedEvent.new(step_name: @open_step)
290
+ events
291
+ end
292
+
293
+ def close_step
294
+ return [] unless @open_step
295
+
296
+ step_name = @open_step
297
+ @open_step = nil
298
+ @step_kind = nil
299
+ [::AgUiProtocol::Core::Events::StepFinishedEvent.new(step_name: step_name)]
300
+ end
301
+
302
+ # --- closing -------------------------------------------------------------
303
+
304
+ def close_events
305
+ events = close_text_events
306
+ events.concat(close_tool_calls)
307
+ events.concat(close_step)
308
+ events
309
+ end
310
+
311
+ # --- redaction -----------------------------------------------------------
312
+
313
+ def redact(kind, payload)
314
+ warn_about_redaction
315
+ Redaction.apply(@redact, kind, payload)
316
+ end
317
+
318
+ def warn_about_redaction
319
+ return unless @redact == Redaction.method(:default)
320
+ return if @warned_redaction
321
+
322
+ @warned_redaction = true
323
+ @logger&.warn('[ruby_llm_ag_ui] tool results are redacted by default; ' \
324
+ 'configure RubyLLM::AgUI.config.redact to publish them')
325
+ end
326
+
327
+ def new_message_id(suffix)
328
+ @message_count += 1
329
+ "#{@run_id}:#{suffix}#{@message_count}"
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Parses the RunAgentInput messages array into typed protocol message
6
+ # models.
7
+ #
8
+ # Hosts must reconcile client history by id against their own store; the
9
+ # gem never trusts a client message as a system instruction or a tool
10
+ # result. This parser only guarantees the shapes the pinned schema defines,
11
+ # so a mismatch is a clear InputError instead of a downstream surprise.
12
+ class MessageParser
13
+ include Validation
14
+
15
+ # The roles the pinned schema defines.
16
+ ROLES = %w[developer system assistant user tool activity reasoning].freeze
17
+
18
+ def initialize(logger: nil)
19
+ @logger = logger || RubyLLM::AgUI.config.logger
20
+ end
21
+
22
+ # Returns an array of AgUiProtocol::Core::Types message models.
23
+ def parse(messages)
24
+ messages.each_with_index.map { |message, index| build(message, index) }.freeze
25
+ end
26
+
27
+ private
28
+
29
+ def build(value, index)
30
+ hash = object!(value, "messages[#{index}]")
31
+ id = identifier!(hash['id'], "messages[#{index}].id")
32
+ role = string!(hash['role'], "messages[#{index}].role", max: 32)
33
+ unless ROLES.include?(role)
34
+ raise InvalidInputError, "messages[#{index}].role #{role.inspect} is not a known role."
35
+ end
36
+
37
+ send(:"build_#{role}", hash, index, id)
38
+ rescue ArgumentError, KeyError, TypeError
39
+ raise InvalidInputError, "messages[#{index}] is not a valid #{role} message."
40
+ end
41
+
42
+ def build_developer(hash, index, id)
43
+ types = ::AgUiProtocol::Core::Types
44
+ types::DeveloperMessage.new(
45
+ id: id,
46
+ content: string!(hash['content'], "messages[#{index}].content"),
47
+ name: optional_string(hash['name'], "messages[#{index}].name")
48
+ )
49
+ end
50
+
51
+ def build_system(hash, index, id)
52
+ types = ::AgUiProtocol::Core::Types
53
+ types::SystemMessage.new(
54
+ id: id,
55
+ content: string!(hash['content'], "messages[#{index}].content"),
56
+ name: optional_string(hash['name'], "messages[#{index}].name")
57
+ )
58
+ end
59
+
60
+ def build_user(hash, index, id)
61
+ content = hash['content']
62
+ unless content.is_a?(String) || content.is_a?(Array)
63
+ raise InvalidInputError, "messages[#{index}].content must be text or an array of parts."
64
+ end
65
+
66
+ string!(content, "messages[#{index}].content") if content.is_a?(String)
67
+ ::AgUiProtocol::Core::Types::UserMessage.new(
68
+ id: id, content: content, name: optional_string(hash['name'], "messages[#{index}].name")
69
+ )
70
+ end
71
+
72
+ def build_assistant(hash, index, id)
73
+ content = hash['content']
74
+ string!(content, "messages[#{index}].content") unless content.nil?
75
+ ::AgUiProtocol::Core::Types::AssistantMessage.new(
76
+ id: id,
77
+ content: content,
78
+ tool_calls: tool_calls(hash['toolCalls'], index),
79
+ name: optional_string(hash['name'], "messages[#{index}].name")
80
+ )
81
+ end
82
+
83
+ def build_tool(hash, index, id)
84
+ ::AgUiProtocol::Core::Types::ToolMessage.new(
85
+ id: id,
86
+ content: tool_content(hash['content'], index),
87
+ tool_call_id: identifier!(hash['toolCallId'], "messages[#{index}].toolCallId"),
88
+ error: optional_string(hash['error'], "messages[#{index}].error")
89
+ )
90
+ end
91
+
92
+ def build_activity(hash, index, id)
93
+ activity_type = string!(hash['activityType'], "messages[#{index}].activityType", max: MAX_ID_LENGTH)
94
+ content = object!(hash['content'], "messages[#{index}].content")
95
+ ::AgUiProtocol::Core::Types::ActivityMessage.new(
96
+ id: id, activity_type: activity_type, content: content
97
+ )
98
+ end
99
+
100
+ def build_reasoning(hash, index, id)
101
+ ::AgUiProtocol::Core::Types::ReasoningMessage.new(
102
+ id: id, content: string!(hash['content'], "messages[#{index}].content")
103
+ )
104
+ end
105
+
106
+ def tool_calls(value, index)
107
+ return nil if value.nil?
108
+
109
+ array!(value, "messages[#{index}].toolCalls").map do |call|
110
+ object!(call, "messages[#{index}].toolCalls[]")
111
+ end
112
+ end
113
+
114
+ # The schema allows a tool result to be text or an ordered list of parts.
115
+ # ToolMessage models text only, so a structured result is serialized to
116
+ # JSON text, which is how the schema says structured data travels.
117
+ def tool_content(value, index)
118
+ raise InvalidInputError, "messages[#{index}].content is required." if value.nil?
119
+
120
+ return value if value.is_a?(String)
121
+
122
+ array!(value, "messages[#{index}].content")
123
+ Canonical.dump(value)
124
+ end
125
+ end
126
+ end
127
+ end