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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The few AG-UI 1.0 wire shapes the community Ruby SDK does not yet model.
6
+ #
7
+ # The protocol SDK owns event models and wire encoding, and this gem reuses
8
+ # them for everything else. Two 1.0 shapes are missing from
9
+ # `ag-ui-protocol` 0.2.0:
10
+ #
11
+ # 1. `RunFinishedCancelledOutcome` — 0.2.0 has success and interrupt
12
+ # outcomes only. A cancellation reported as success is exactly the bug
13
+ # this gem exists to prevent, so the gem emits a schema-valid cancelled
14
+ # outcome and offers the model upstream.
15
+ # 2. `RUN_STARTED.protocolVersion` — 1.0 lets a producer declare the
16
+ # version it speaks so a consumer can see a downgrade. 0.2.0's
17
+ # RunStartedEvent has no such field.
18
+ #
19
+ # Both classes subclass the SDK's own models, so the SDK encoder serializes
20
+ # them unchanged. They define no top-level `AgUi` constant.
21
+ module Protocol
22
+ # The AG-UI protocol version this producer speaks.
23
+ PROTOCOL_VERSION = '1.0'
24
+
25
+ # The pinned upstream commit of docs/spec/1.0/schema.json.
26
+ SCHEMA_COMMIT = '89cedd3fd549feeee41e50e1d7c6d373e549241a'
27
+
28
+ # RunFinishedOutcome with the 1.0 "cancelled" discriminator.
29
+ #
30
+ # A cancelled run was stopped before it completed and did not fail. It
31
+ # carries no interrupts, so the next run on the thread is an ordinary new
32
+ # run rather than a resume.
33
+ class CancelledOutcome < ::AgUiProtocol::Core::Types::Model
34
+ # The discriminator value the 1.0 schema requires.
35
+ TYPE = 'cancelled'
36
+
37
+ attr_reader :type
38
+
39
+ def initialize(type: nil)
40
+ raise ArgumentError, "CancelledOutcome.type must be #{TYPE.inspect}" if !type.nil? && type != TYPE
41
+
42
+ super()
43
+ @type = TYPE
44
+ end
45
+
46
+ def to_h
47
+ { type: @type }
48
+ end
49
+ end
50
+
51
+ # RunStartedEvent that declares the producer's protocol version.
52
+ class RunStartedEvent < ::AgUiProtocol::Core::Events::RunStartedEvent
53
+ attr_reader :protocol_version
54
+
55
+ def initialize(protocol_version: PROTOCOL_VERSION, **rest)
56
+ @protocol_version = protocol_version
57
+ super(**rest)
58
+ end
59
+
60
+ def to_h
61
+ super.merge(protocol_version: @protocol_version)
62
+ end
63
+ end
64
+
65
+ # RunFinishedEvent that accepts the cancelled outcome.
66
+ #
67
+ # The SDK's own RunFinishedEvent validates its +outcome+ against the two
68
+ # outcomes 0.2.0 models, so it cannot carry a cancelled one. This class
69
+ # has the same shape and the same RUN_FINISHED type; it accepts any
70
+ # outcome object that serializes to a schema-valid RunFinishedOutcome,
71
+ # including the SDK's success and interrupt outcomes.
72
+ class RunFinishedEvent < ::AgUiProtocol::Core::Events::BaseEvent
73
+ attr_reader :thread_id, :run_id, :result, :outcome, :usage
74
+
75
+ # Mirrors the SDK's own RunFinishedEvent signature.
76
+ def initialize(thread_id:, run_id:, result: nil, outcome: nil, usage: nil, timestamp: nil, raw_event: nil) # rubocop:disable Metrics/ParameterLists
77
+ if !result.nil? && !outcome.nil?
78
+ raise ArgumentError, 'result and outcome are mutually exclusive; provide one or the other, not both'
79
+ end
80
+
81
+ super(type: ::AgUiProtocol::Core::Events::EventType::RUN_FINISHED, timestamp: timestamp, raw_event: raw_event)
82
+ @thread_id = thread_id
83
+ @run_id = run_id
84
+ @result = result
85
+ @outcome = outcome
86
+ @usage = usage
87
+ end
88
+
89
+ def to_h
90
+ super.merge(thread_id: @thread_id, run_id: @run_id, result: @result, outcome: @outcome, usage: @usage)
91
+ end
92
+ end
93
+
94
+ module_function
95
+
96
+ # The RUN_STARTED this producer emits: the SDK's shape plus the protocol
97
+ # version only 1.0 carries.
98
+ def run_started(thread_id:, run_id:, parent_run_id: nil, input: nil)
99
+ RunStartedEvent.new(thread_id: thread_id, run_id: run_id, parent_run_id: parent_run_id, input: input)
100
+ end
101
+
102
+ # A RUN_FINISHED carrying +outcome+.
103
+ def run_finished(thread_id:, run_id:, outcome:)
104
+ RunFinishedEvent.new(thread_id: thread_id, run_id: run_id, outcome: outcome)
105
+ end
106
+
107
+ # The outcome of a run that ended without a human in the loop.
108
+ def success_outcome
109
+ ::AgUiProtocol::Core::Events::RunFinishedSuccessOutcome.new
110
+ end
111
+
112
+ # The 1.0 cancelled outcome 0.2.0 cannot express.
113
+ def cancelled_outcome
114
+ CancelledOutcome.new
115
+ end
116
+
117
+ # The outcome of a run that paused, carrying the interrupts that still
118
+ # wait for an answer. The schema requires at least one, so a run whose
119
+ # interrupts were all answered is not closed with this outcome.
120
+ def interrupt_outcome(interrupts)
121
+ ::AgUiProtocol::Core::Events::RunFinishedInterruptOutcome.new(interrupts: interrupts.map(&:to_protocol))
122
+ end
123
+
124
+ # The complete state a reconnect serves instead of replaying deltas.
125
+ def state_snapshot(state)
126
+ ::AgUiProtocol::Core::Events::StateSnapshotEvent.new(snapshot: state)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'record'
4
+
5
+ module RubyLLM
6
+ module AgUI
7
+ module Rails
8
+ # The durable run store: run identity in a table instead of a process.
9
+ #
10
+ # The in-process store is correct for one process only. A host that runs
11
+ # more than one Puma worker, or that restarts, needs the reservation, the
12
+ # interrupts and the terminal status to outlive the worker that produced
13
+ # them, which is what this store is for.
14
+ #
15
+ # Ordering is by row id, so "the latest run on the thread" means the run
16
+ # reserved last, not the run whose clock reading is largest. Two requests
17
+ # that arrive in the same millisecond still have an order.
18
+ #
19
+ # RubyLLM::AgUI.configure { |config| config.run_store = ActiveRecordStore.new }
20
+ class ActiveRecordStore < Store::Base
21
+ # Claims (scope, thread_id, run_id) for +digest+.
22
+ #
23
+ # The unique index on the identity is the real guard: two workers that
24
+ # insert at the same time cannot both win, and the loser attaches to the
25
+ # row the winner wrote instead of executing the turn a second time.
26
+ def reserve(scope:, thread_id:, run_id:, digest:)
27
+ scope_key(scope)
28
+ record = RunRecord.new(
29
+ scope: scope, thread_id: thread_id, run_id: run_id, digest: digest, created_at: now
30
+ )
31
+ Record.from_run_record(record).save!
32
+ Reservation.new(record: record, status: Reservation::CREATED)
33
+ rescue ::ActiveRecord::RecordNotUnique
34
+ attach(row_for(scope, thread_id, run_id), digest)
35
+ end
36
+
37
+ def find(scope:, thread_id:, run_id:)
38
+ row_for(scope, thread_id, run_id)&.as_run_record
39
+ end
40
+
41
+ def latest_run(scope:, thread_id:)
42
+ thread_rows(scope, thread_id).last&.as_run_record
43
+ end
44
+
45
+ def latest_interrupted(scope:, thread_id:)
46
+ interrupted = RunRecord::INTERRUPTED.to_s
47
+ thread_rows(scope, thread_id).reverse.find { |row| row.status == interrupted }&.as_run_record
48
+ end
49
+
50
+ def save(record)
51
+ scope_key(record.scope)
52
+ row = row_for(record.scope, record.thread_id, record.run_id)
53
+ raise InvalidRunIdentityError, "run #{record.run_id} was never reserved" if row.nil?
54
+ raise RunConflictError, conflict_message(record) if row.digest != record.digest
55
+
56
+ row.write_run_record(record)
57
+ end
58
+
59
+ def find_interrupt(scope:, thread_id:, interrupt_id:)
60
+ raise InvalidInputError, 'interrupt_id is required.' if blank?(interrupt_id)
61
+
62
+ row_for_interrupt(scope, thread_id, interrupt_id)&.as_run_record&.interrupt(interrupt_id)
63
+ end
64
+
65
+ # Records +decision+ on the interrupt under a row lock, so two resumes
66
+ # that arrive together cannot both record a decision and run the tool.
67
+ def redeem_interrupt(scope:, thread_id:, interrupt_id:, decision:, now:)
68
+ unless Interrupt::DECISIONS.include?(decision)
69
+ raise ConfigurationError, "decision must be one of #{Interrupt::DECISIONS.join(', ')}"
70
+ end
71
+
72
+ row = row_for_interrupt(scope, thread_id, interrupt_id)
73
+ raise ResumeRejectedError, "no interrupt #{interrupt_id}" if row.nil?
74
+
75
+ row.with_lock { redeem(row.reload.as_run_record, interrupt_id, decision, now) }
76
+ end
77
+
78
+ private
79
+
80
+ # A retry of the same identity attaches; a different input under the
81
+ # same key is a conflict, because one run id may not mean two requests.
82
+ def attach(row, digest)
83
+ raise RunConflictError, conflict_message(row.as_run_record) if row.digest != digest
84
+
85
+ Reservation.new(record: row.reload.as_run_record, status: Reservation::ATTACHED)
86
+ end
87
+
88
+ def redeem(record, interrupt_id, decision, now)
89
+ interrupt = record.interrupt(interrupt_id)
90
+ return replay(interrupt, decision) if interrupt.decided?
91
+ raise ResumeRejectedError, "interrupt #{interrupt_id} expired" if interrupt.expired?(now)
92
+
93
+ answered = interrupt.decide(decision, now: now)
94
+ row = row_for(record.scope, record.thread_id, record.run_id)
95
+ row.write_run_record(record.replace_interrupt(answered, now: now))
96
+ Redemption.new(interrupt: answered, replayed: false)
97
+ end
98
+
99
+ def row_for(scope, thread_id, run_id)
100
+ return nil if blank?(scope) || blank?(thread_id) || blank?(run_id)
101
+
102
+ Record.find_by(scope: scope, thread_id: thread_id, run_id: run_id)
103
+ end
104
+
105
+ def thread_rows(scope, thread_id)
106
+ Record.where(scope: scope, thread_id: thread_id).order(:id).to_a
107
+ end
108
+
109
+ # An interrupt id is unique among a thread's runs, so the thread is
110
+ # searched newest first and the first row that knows the id wins. A
111
+ # thread holds few runs, and the alternative is a JSON query that would
112
+ # bind this store to one database.
113
+ def row_for_interrupt(scope, thread_id, interrupt_id)
114
+ thread_rows(scope, thread_id).reverse.find { |row| row.as_run_record.interrupt(interrupt_id) }
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ module Rails
6
+ # Loads the optional Rails adapter, and nothing else.
7
+ #
8
+ # The core is pure Ruby and `require 'ruby_llm/ag_ui'` loads no Rails. This
9
+ # file is required only when Rails is already present, and it loads the
10
+ # adapter's parts in dependency order, skipping the Active Record store
11
+ # when the host has no Active Record at all. Calling it twice is harmless.
12
+ def self.setup!
13
+ require_relative 'streaming'
14
+ return unless defined?(::ActiveRecord::Base)
15
+
16
+ require_relative 'active_record_store'
17
+ end
18
+
19
+ # The railtie that loads the adapter during boot.
20
+ class Railtie < ::Rails::Railtie
21
+ initializer 'ruby_llm.ag_ui' do
22
+ RubyLLM::AgUI::Rails.setup!
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ RubyLLM::AgUI::Rails.setup!
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module RubyLLM
6
+ module AgUI
7
+ module Rails
8
+ # The durable form of one core run record.
9
+ #
10
+ # The core RunRecord stays the authority; this row is where it lives
11
+ # between requests, so a run reserved on one Puma worker is the same run on
12
+ # the next. Identity is duplicated out of the payload into indexed columns
13
+ # so a thread can be listed without parsing JSON, and the payload is
14
+ # written whole because a record is immutable: there is no partial update
15
+ # to make.
16
+ #
17
+ # The payload is JSON *text*, not a database JSON column. The record is
18
+ # never queried by content, and encoding it here keeps the stored shape
19
+ # exactly `RunRecord#to_storage` on every database and every Rails version,
20
+ # instead of inheriting whichever encoder the host's ActiveSupport picks.
21
+ #
22
+ # Create the table with `bin/rails generate ruby_llm:ag_ui:install`.
23
+ class Record < ::ActiveRecord::Base
24
+ self.table_name = 'ruby_llm_ag_ui_runs'
25
+
26
+ # The core record this row stores.
27
+ def as_run_record
28
+ ::RubyLLM::AgUI::RunRecord.from_storage(::JSON.parse(payload))
29
+ end
30
+
31
+ # Writes +record+ onto this row and returns the record.
32
+ #
33
+ # The identity columns are not writable: a row keeps the identity it was
34
+ # reserved with, so a record cannot be stored under another run's key.
35
+ def write_run_record(record)
36
+ update!(digest: record.digest, status: record.status.to_s, sequence: record.sequence,
37
+ payload: ::JSON.generate(record.to_storage))
38
+ record
39
+ end
40
+
41
+ # An unsaved row carrying +record+.
42
+ def self.from_run_record(record)
43
+ new(
44
+ scope: record.scope, thread_id: record.thread_id, run_id: record.run_id, digest: record.digest,
45
+ status: record.status.to_s, sequence: record.sequence, payload: ::JSON.generate(record.to_storage)
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ module Rails
6
+ # The controller concern that serves one AG-UI run as one SSE response.
7
+ #
8
+ # class AgentRunsController < ApplicationController
9
+ # include RubyLLM::AgUI::Rails::Streaming
10
+ #
11
+ # def create
12
+ # stream_ag_ui_run do |input|
13
+ # chat = ag_ui_chats.find_by!(ag_ui_thread_id: input.thread_id)
14
+ # authorize! chat
15
+ # chat
16
+ # end
17
+ # end
18
+ # end
19
+ #
20
+ # What the host owns, and the concern never guesses:
21
+ #
22
+ # * authentication (a `before_action`, or the action itself);
23
+ # * `#ag_ui_scope`, the tenant/actor key the store is bounded by — the
24
+ # concern refuses to run without one, so an unauthenticated request
25
+ # cannot reserve a run;
26
+ # * the chat the block returns, built with only the tools and the model
27
+ # that actor may use. The client's `messages`, `tools` and `state` are
28
+ # data for that chat; they authorize nothing.
29
+ #
30
+ # Refusals are decided before the response is committed, so a client that
31
+ # cannot read an event stream, or that sent an input the gem will not
32
+ # accept, gets a status code instead of half a run.
33
+ module Streaming
34
+ extend ::ActiveSupport::Concern
35
+
36
+ # A client that cannot read an event stream, and tells us so.
37
+ NOT_ACCEPTABLE = 406
38
+ # `resolve_chat` found no chat for the thread this actor owns.
39
+ NOT_FOUND = 404
40
+ # The run cannot be served: a live run already has a stream, or the
41
+ # transport is gone.
42
+ CONFLICT = 409
43
+
44
+ included do
45
+ include ::ActionController::Live
46
+ end
47
+
48
+ # Streams one run and returns the run's record, or nil when the client
49
+ # disconnected. The block receives the parsed input and returns the chat
50
+ # for it.
51
+ def stream_ag_ui_run(&resolve_chat)
52
+ if resolve_chat.nil?
53
+ raise ConfigurationError, 'stream_ag_ui_run needs a block that returns the chat for the input'
54
+ end
55
+
56
+ return reject_ag_ui_request(NOT_ACCEPTABLE, accept_message) unless ag_ui_accept?
57
+
58
+ input = ag_ui_input
59
+ chat = resolve_chat.call(input)
60
+ run = ag_ui_run(chat, input)
61
+
62
+ ag_ui_stream(run)
63
+ rescue ::RubyLLM::AgUI::InputError => e
64
+ # Every refusal the gem itself decides carries the status it should be
65
+ # answered with: 400 for an input that cannot be accepted, 409 for an
66
+ # identity claimed with different input or a resume that answers
67
+ # nothing, 413 for a body past the configured bound.
68
+ reject_ag_ui_request(e.http_status, e.message)
69
+ rescue ::ActiveRecord::RecordNotFound
70
+ reject_ag_ui_request(NOT_FOUND, 'There is no such thread.')
71
+ rescue ::RubyLLM::AgUI::StreamError => e
72
+ reject_ag_ui_request(CONFLICT, e.message)
73
+ end
74
+
75
+ # The tenant/actor key every reservation is bounded by.
76
+ #
77
+ # It must come from the authenticated request, not from the input: a
78
+ # client-chosen scope would let one actor attach to another's run.
79
+ def ag_ui_scope
80
+ raise ConfigurationError,
81
+ "#{self.class} must define #ag_ui_scope, built from the authenticated tenant and actor"
82
+ end
83
+
84
+ # The state to publish for a reconnect, or nil. The host's own run state
85
+ # is not the gem's to guess.
86
+ def ag_ui_state
87
+ nil
88
+ end
89
+
90
+ private
91
+
92
+ # Whether this client can read an event stream. An AG-UI client asks for
93
+ # `text/event-stream`; a request that asks for something else is told so
94
+ # instead of being streamed at.
95
+ def ag_ui_accept?
96
+ SSEWriter.accepts?(request.headers['Accept'])
97
+ end
98
+
99
+ def accept_message
100
+ "This endpoint streams #{SSEWriter::CONTENT_TYPE}. Send `Accept: #{SSEWriter::CONTENT_TYPE}`."
101
+ end
102
+
103
+ def ag_ui_input
104
+ ::RubyLLM::AgUI::Input.parse(ag_ui_body)
105
+ end
106
+
107
+ # The request body, bounded before it is parsed: an agent input is small,
108
+ # and a host must not read an unbounded body from a request into memory.
109
+ # The errors name the status a client should be answered with.
110
+ def ag_ui_body
111
+ limit = RubyLLM::AgUI.config.max_input_bytes
112
+ body = request.body.read(limit + 1)
113
+ raise MalformedInputError, 'the request body is empty.' if body.nil? || body.strip.empty?
114
+ raise InputTooLargeError, "the request body exceeds #{limit} bytes." if body.bytesize > limit
115
+
116
+ JSON.parse(body)
117
+ rescue JSON::ParserError
118
+ raise MalformedInputError, 'the request body is not JSON.'
119
+ end
120
+
121
+ def ag_ui_run(chat, input)
122
+ ::RubyLLM::AgUI::Run.new(
123
+ chat: chat, input: input, scope: ag_ui_scope, state: ag_ui_state
124
+ )
125
+ end
126
+
127
+ # Opens the SSE response, runs, and closes the stream whatever happens.
128
+ #
129
+ # A client that disconnects mid-run is not a run outcome: the record the
130
+ # store already holds says how the run ended, and a truncated stream says
131
+ # only that this client stopped reading.
132
+ def ag_ui_stream(run)
133
+ response.headers.merge!(SSEWriter.headers)
134
+ stream = response.stream
135
+ begin
136
+ run.stream(to: stream)
137
+ rescue ::ActionController::Live::ClientDisconnected, ::IOError
138
+ nil
139
+ rescue ::RubyLLM::AgUI::StreamError
140
+ # Before the first write this is a refusal the client can still be
141
+ # told about, so it goes back up. After it, the stream is already
142
+ # broken and the record in the store is the run's outcome: a
143
+ # truncated stream says only that this client stopped reading.
144
+ raise unless response.committed?
145
+
146
+ nil
147
+ ensure
148
+ stream.close
149
+ end
150
+ end
151
+
152
+ # Renders a refusal as a plain response. Everything refused here is
153
+ # refused before the run writes anything, so the client gets a status
154
+ # code rather than a stream that stops mid-event.
155
+ def reject_ag_ui_request(status, message)
156
+ if response.committed?
157
+ raise StreamError, 'the response is already committed; a refusal must be decided before the run starts'
158
+ end
159
+
160
+ response.headers['Content-Type'] = 'text/plain; charset=utf-8'
161
+ render plain: message, status: status
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # What a client that re-sends an input whose run already ended gets (FR-05).
6
+ #
7
+ # A reconnect is not a second execution. The run ended, so the client gets
8
+ # its outcome and the state snapshot the run recorded instead of the deltas
9
+ # it may already have — or may have missed. Replaying deltas would apply text
10
+ # twice and could run a tool twice, so they are never replayed.
11
+ #
12
+ # What cannot be served this way is a run that has not ended: attaching a
13
+ # second stream to a live run needs a relay the gem does not own, and a
14
+ # silent second stream would duplicate the work. Those are refused.
15
+ class Reconnect
16
+ # The failure reported for a run that failed before its sanitized error
17
+ # was recorded — a record from an older release, or one whose write was
18
+ # interrupted.
19
+ UNRECORDED_FAILURE = {
20
+ 'message' => 'The run failed before it could finish.', 'code' => 'internal_error'
21
+ }.freeze
22
+
23
+ # Whether +record+ can be served to a client that reconnects.
24
+ def self.serves?(record)
25
+ record.terminal?
26
+ end
27
+
28
+ def initialize(record:, input:)
29
+ @record = record
30
+ @input = input
31
+ end
32
+
33
+ # The events to write, in order: the run opens, declares the state it owns,
34
+ # and reports the outcome already recorded.
35
+ def events
36
+ [open_event, *snapshot_events, terminal_event]
37
+ end
38
+
39
+ private
40
+
41
+ def open_event
42
+ Protocol.run_started(
43
+ thread_id: @record.thread_id, run_id: @record.run_id,
44
+ parent_run_id: @input.parent_run_id, input: @input.to_h
45
+ )
46
+ end
47
+
48
+ def snapshot_events
49
+ return [] if @record.snapshot.nil?
50
+
51
+ [Protocol.state_snapshot(@record.snapshot)]
52
+ end
53
+
54
+ def terminal_event
55
+ case @record.status
56
+ when RunRecord::SUCCEEDED then finished(Protocol.success_outcome)
57
+ when RunRecord::CANCELLED then finished(Protocol.cancelled_outcome)
58
+ when RunRecord::INTERRUPTED then interrupted_event
59
+ else failure_event
60
+ end
61
+ end
62
+
63
+ def finished(outcome)
64
+ Protocol.run_finished(thread_id: @record.thread_id, run_id: @record.run_id, outcome: outcome)
65
+ end
66
+
67
+ # An interrupted run whose interrupts have all been answered was continued
68
+ # by a resume run, which owns what happened next. The run therefore has no
69
+ # interrupt to report (the schema requires at least one), and saying so is
70
+ # more honest than reporting answered interrupts as if they still waited.
71
+ def interrupted_event
72
+ return finished(Protocol.interrupt_outcome(@record.pending_interrupts)) if @record.pending_interrupts.any?
73
+
74
+ ::AgUiProtocol::Core::Events::RunErrorEvent.new(
75
+ message: 'This run ended interrupted, and every interrupt has been answered.',
76
+ code: 'run_resumed'
77
+ )
78
+ end
79
+
80
+ def failure_event
81
+ failure = @record.failure || UNRECORDED_FAILURE
82
+ ::AgUiProtocol::Core::Events::RunErrorEvent.new(message: failure['message'], code: failure['code'])
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The policy that decides what a tool result or activity payload may put on
6
+ # the wire.
7
+ #
8
+ # A tool result is arbitrary host data on its way to a browser. It may hold
9
+ # an API key, a customer record or an internal path, and the gem has no way
10
+ # to know which part is a secret. So the default policy publishes the shape
11
+ # and withholds the content, and a host that has decided its payloads are
12
+ # safe says so explicitly:
13
+ #
14
+ # RubyLLM::AgUI.configure do |config|
15
+ # config.redact = RubyLLM::AgUI::Redaction.method(:none) # publish raw
16
+ # config.redact = ->(kind, payload) { MyPolicy.call(kind, payload) }
17
+ # end
18
+ #
19
+ # A pattern-matching scrubber is deliberately not the default: it would
20
+ # create confidence in something that cannot be correct, because the gem
21
+ # cannot know what the host considers personal.
22
+ module Redaction
23
+ # What a redacted value becomes.
24
+ PLACEHOLDER = '[redacted]'
25
+
26
+ # The kinds a policy is asked about.
27
+ TOOL_RESULT = :tool_result
28
+
29
+ module_function
30
+
31
+ # The default policy: keep the shape, withhold the content.
32
+ #
33
+ # A Hash keeps its keys, an Array keeps its length, and a scalar becomes
34
+ # the placeholder, so a payload still validates against the schema it
35
+ # travels in while carrying none of its values.
36
+ def default(_kind, payload)
37
+ case payload
38
+ when nil, '' then payload
39
+ when Hash then payload.to_h { |key, _value| [key, PLACEHOLDER] }
40
+ when Array then payload.map { PLACEHOLDER }
41
+ else PLACEHOLDER
42
+ end
43
+ end
44
+
45
+ # The opt-out: publish the payload unchanged. Use it only when the host
46
+ # has established that the payload is safe to show.
47
+ def none(_kind, payload)
48
+ payload
49
+ end
50
+
51
+ # Calls +callable+ with the arguments its arity takes, so a host can write
52
+ # either `->(kind, payload) { ... }` or the terse `->(payload) { ... }`.
53
+ def apply(callable, kind, payload)
54
+ callable.arity == 1 ? callable.call(payload) : callable.call(kind, payload)
55
+ end
56
+ end
57
+ end
58
+ end