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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Writes AG-UI events to any IO-like stream as Server-Sent Events.
6
+ #
7
+ # The stream is whatever the host is writing a response to: a Rack response
8
+ # body, an ActionController::Live response, a socket, a StringIO in a test.
9
+ # The gem does not own the transport, so it does not open connections,
10
+ # buffer frames or choose how a host relays a background job to a client.
11
+ # What it does own is the ordering and the framing of the events it writes.
12
+ #
13
+ # A dead stream is reported as a StreamError and the writer stays closed.
14
+ # The reason is a transport truncation, not a run outcome: it is never
15
+ # evidence that the work finished, succeeded or was cancelled.
16
+ class SSEWriter
17
+ # The media type an SSE response must be served as.
18
+ CONTENT_TYPE = 'text/event-stream'
19
+
20
+ # SSE responses must not be buffered by an intermediary.
21
+ CACHE_CONTROL = 'no-cache'
22
+
23
+ # Keep-alive for the proxies that need it. Puma itself does not.
24
+ CONNECTION = 'keep-alive'
25
+
26
+ # A response that streams must not be compressed, because compression
27
+ # buffers and withholds the frames the client is waiting for.
28
+ NO_TRANSFORM = 'no'
29
+
30
+ # The number of events successfully written.
31
+ attr_reader :events_written
32
+
33
+ # The bytes successfully written.
34
+ attr_reader :bytes_written
35
+
36
+ def initialize(stream, encoder: nil, logger: nil)
37
+ @stream = stream
38
+ # The SDK encoder always writes SSE; its accept parameter only names the
39
+ # media type, and this gem always serves text/event-stream.
40
+ @encoder = encoder || ::AgUiProtocol::EventEncoder.new
41
+ @logger = logger || RubyLLM::AgUI.config.logger
42
+ @events_written = 0
43
+ @bytes_written = 0
44
+ @closed = false
45
+ end
46
+
47
+ # Whether an Accept header means the client can read an SSE stream.
48
+ #
49
+ # A host calls this before opening the stream. An explicit header that
50
+ # cannot accept `text/event-stream` is a client that will not understand
51
+ # the response, so the host should answer 406 instead of streaming.
52
+ def self.accepts?(accept)
53
+ return true if accept.nil? || accept.strip.empty?
54
+
55
+ accept.split(',').any? do |range|
56
+ %w[text/event-stream text/* */*].include?(range.split(';').first.to_s.strip.downcase)
57
+ end
58
+ end
59
+
60
+ # The Content-Type for the response the host is about to open.
61
+ def content_type
62
+ @encoder.content_type
63
+ end
64
+
65
+ # The response headers a host should send before the first event.
66
+ def headers
67
+ self.class.headers(content_type)
68
+ end
69
+
70
+ # The response headers a host should send before the first event, for the
71
+ # default media type. A host that mounts a run from a controller merges
72
+ # these without having a stream yet.
73
+ def self.headers(content_type = CONTENT_TYPE)
74
+ {
75
+ 'Content-Type' => "#{content_type}; charset=utf-8",
76
+ 'Cache-Control' => CACHE_CONTROL,
77
+ 'Connection' => CONNECTION,
78
+ 'X-Accel-Buffering' => NO_TRANSFORM
79
+ }
80
+ end
81
+
82
+ # Encodes +event+ and writes it, then flushes so the client sees it now.
83
+ # Returns the encoded frame.
84
+ def write(event)
85
+ raise StreamError, 'The event stream is closed.' if @closed
86
+
87
+ frame = @encoder.encode(event)
88
+ @stream.write(frame)
89
+ flush
90
+ @events_written += 1
91
+ @bytes_written += frame.bytesize
92
+ frame
93
+ rescue IOError, SystemCallError => e
94
+ # The client is gone. The class name is enough for the host log; the
95
+ # message may embed a path or a socket address and is not echoed.
96
+ @logger&.warn("[ruby_llm_ag_ui] the event stream failed: #{e.class}")
97
+ @closed = true
98
+ raise StreamError, "The event stream failed: #{e.class}"
99
+ end
100
+
101
+ # Pushes buffered bytes to the client. A no-op for a stream that cannot
102
+ # flush, such as a Rack response body.
103
+ def flush
104
+ @stream.flush if @stream.respond_to?(:flush)
105
+ end
106
+
107
+ # Marks the stream closed. The host calls this once the run is over; a
108
+ # later write raises.
109
+ def close
110
+ @closed = true
111
+ end
112
+
113
+ # Whether the stream can no longer take events.
114
+ def closed?
115
+ @closed
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Run identity and progress storage.
6
+ #
7
+ # The store is where run identity becomes durable: it reserves a run before
8
+ # any provider work happens, so a retried request attaches to the run
9
+ # already in progress instead of executing twice, and it keeps the
10
+ # interrupts that a later resume has to answer.
11
+ #
12
+ # Two stores ship with the gem: Store::MemoryStore, which is the default for
13
+ # a single process, and the optional Rails adapter's ActiveRecord store. A
14
+ # host may implement this interface instead; every method must be safe to
15
+ # call from concurrent threads.
16
+ #
17
+ # `scope` is the host's own tenant/actor boundary. The store treats it as an
18
+ # opaque key, so the host must build it from authenticated identity (for
19
+ # example `"#{tenant.id}:#{actor.id}"`). Two scopes never share state, which
20
+ # is what stops one tenant from attaching to another's run.
21
+ module Store
22
+ # The interface every store implements. Subclass it to get the documented
23
+ # contract in one place, or duck-type it.
24
+ #
25
+ # The private helpers below are not part of the interface: they are the
26
+ # parts of the contract two stores would otherwise implement twice (the
27
+ # scope key, the clock, the replay rule). A store that duck-types the
28
+ # public methods has to satisfy them itself.
29
+ class Base
30
+ # Raised when a host passes a scope that cannot identify a tenant.
31
+ SCOPE_MESSAGE = 'scope must be a non-empty String built from authenticated identity'
32
+
33
+ def initialize(clock: nil)
34
+ @clock = clock || RubyLLM::AgUI.config.clock
35
+ end
36
+
37
+ # Claims (scope, thread_id, run_id) for +digest+.
38
+ #
39
+ # Returns a Reservation whose record is the run's authoritative state.
40
+ # A second call with the same key and an identical digest returns an
41
+ # ATTACHED reservation; a different digest raises RunConflictError,
42
+ # because the same run id may not mean two different requests.
43
+ def reserve(scope:, thread_id:, run_id:, digest:)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # The record for an exact identity, or nil.
48
+ def find(scope:, thread_id:, run_id:)
49
+ raise NotImplementedError
50
+ end
51
+
52
+ # The most recent run on the thread. Used to answer "what is this thread
53
+ # doing right now" without executing anything.
54
+ def latest_run(scope:, thread_id:)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ # The most recent run on the thread that ended interrupted, or nil.
59
+ #
60
+ # A resume entry may only answer an interrupt of this run; anything
61
+ # older is stale and must be refused.
62
+ def latest_interrupted(scope:, thread_id:)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ # Persists an updated record and returns it.
67
+ def save(record)
68
+ raise NotImplementedError
69
+ end
70
+
71
+ # The interrupt with +id+ anywhere on the thread, or nil.
72
+ def find_interrupt(scope:, thread_id:, interrupt_id:)
73
+ raise NotImplementedError
74
+ end
75
+
76
+ # Atomically records +decision+ on the interrupt with +interrupt_id+ and
77
+ # returns a Redemption.
78
+ #
79
+ # The first answer wins. A repeated answer with the same decision
80
+ # returns the stored interrupt as a replay, so a replayed resume request
81
+ # cannot run the tool twice; Redemption#recorded? says which call won.
82
+ # An expired interrupt, an unknown id, or a second answer that disagrees
83
+ # with the stored decision raises ResumeRejectedError: nothing is
84
+ # recorded and nothing is executed.
85
+ def redeem_interrupt(scope:, thread_id:, interrupt_id:, decision:, now:)
86
+ raise NotImplementedError
87
+ end
88
+
89
+ private
90
+
91
+ # The host's scope must be a canonical string. An arbitrary object would
92
+ # make two requests from the same tenant hash differently, so a retry
93
+ # would not attach and the run would execute twice.
94
+ def scope_key(scope)
95
+ raise ConfigurationError, SCOPE_MESSAGE unless scope.is_a?(String) && !scope.empty?
96
+
97
+ scope
98
+ end
99
+
100
+ def now
101
+ @clock.call
102
+ end
103
+
104
+ def blank?(value)
105
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
106
+ end
107
+
108
+ # A repeat of the stored decision is a replay: the caller is told the
109
+ # answer already given and must not execute the tool again. A second
110
+ # answer that disagrees rescinds nothing and is refused.
111
+ def replay(interrupt, decision)
112
+ return Redemption.new(interrupt: interrupt, replayed: true) if interrupt.status == decision
113
+
114
+ raise ResumeRejectedError, "interrupt #{interrupt.id} was already #{interrupt.status}"
115
+ end
116
+
117
+ def conflict_message(record)
118
+ "run #{record.run_id} on thread #{record.thread_id} was already reserved with a different input"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ module Store
6
+ # An in-process run store.
7
+ #
8
+ # It is correct for a single process and a reasonable default in
9
+ # development and tests. It is not shared between processes, so a host
10
+ # that runs more than one Puma worker, or retries on a different worker,
11
+ # must use a durable store (the optional Rails adapter's ActiveRecord
12
+ # store) instead. Nothing here pretends otherwise.
13
+ #
14
+ # Every public method holds the store's lock for its whole body, so the
15
+ # reservation and the interrupt redemption are atomic: the first caller to
16
+ # answer an interrupt is the only one that records a decision. The private
17
+ # helpers assume the lock is already held and never take it themselves.
18
+ class MemoryStore < Base
19
+ def initialize(clock: nil)
20
+ super
21
+ @records = {}
22
+ @threads = {}
23
+ @lock = Mutex.new
24
+ end
25
+
26
+ def reserve(scope:, thread_id:, run_id:, digest:)
27
+ key = key_for(scope, thread_id, run_id)
28
+ @lock.synchronize do
29
+ existing = @records[key]
30
+ next attach(existing, digest) if existing
31
+
32
+ record = RunRecord.new(
33
+ scope: scope, thread_id: thread_id, run_id: run_id, digest: digest, created_at: now
34
+ )
35
+ @records[key] = record
36
+ thread = thread_key(scope, thread_id)
37
+ @threads[thread] = (@threads[thread] || []) + [run_id]
38
+ Reservation.new(record: record, status: Reservation::CREATED)
39
+ end
40
+ end
41
+
42
+ def find(scope:, thread_id:, run_id:)
43
+ key = key_for(scope, thread_id, run_id)
44
+ @lock.synchronize { @records[key] }
45
+ end
46
+
47
+ def latest_run(scope:, thread_id:)
48
+ @lock.synchronize { records_for(scope, thread_id).last }
49
+ end
50
+
51
+ def latest_interrupted(scope:, thread_id:)
52
+ @lock.synchronize { records_for(scope, thread_id).reverse.find(&:interrupted?) }
53
+ end
54
+
55
+ def save(record)
56
+ key = key_for(record.scope, record.thread_id, record.run_id)
57
+ @lock.synchronize do
58
+ stored = @records[key]
59
+ raise InvalidRunIdentityError, "run #{record.run_id} was never reserved" if stored.nil?
60
+ raise RunConflictError, conflict_message(record) if stored.digest != record.digest
61
+
62
+ @records[key] = record
63
+ end
64
+ record
65
+ end
66
+
67
+ def find_interrupt(scope:, thread_id:, interrupt_id:)
68
+ raise InvalidInputError, 'interrupt_id is required.' if blank?(interrupt_id)
69
+
70
+ @lock.synchronize { record_for_interrupt(scope, thread_id, interrupt_id)&.interrupt(interrupt_id) }
71
+ end
72
+
73
+ def redeem_interrupt(scope:, thread_id:, interrupt_id:, decision:, now:)
74
+ unless Interrupt::DECISIONS.include?(decision)
75
+ raise ConfigurationError, "decision must be one of #{Interrupt::DECISIONS.join(', ')}"
76
+ end
77
+
78
+ @lock.synchronize { redeem(scope, thread_id, interrupt_id, decision, now) }
79
+ end
80
+
81
+ private
82
+
83
+ def redeem(scope, thread_id, interrupt_id, decision, now)
84
+ record = record_for_interrupt(scope, thread_id, interrupt_id)
85
+ raise ResumeRejectedError, "no interrupt #{interrupt_id}" if record.nil?
86
+
87
+ interrupt = record.interrupt(interrupt_id)
88
+ return replay(interrupt, decision) if interrupt.decided?
89
+ raise ResumeRejectedError, "interrupt #{interrupt_id} expired" if interrupt.expired?(now)
90
+
91
+ answered = interrupt.decide(decision, now: now)
92
+ @records[key_for(scope, thread_id, record.run_id)] = record.replace_interrupt(answered, now: now)
93
+ Redemption.new(interrupt: answered, replayed: false)
94
+ end
95
+
96
+ # A repeat of the stored decision is a replay: the caller is told the
97
+ # answer already given and must not execute the tool again.
98
+ def record_for_interrupt(scope, thread_id, interrupt_id)
99
+ records_for(scope, thread_id).reverse.find { |record| record.interrupt(interrupt_id) }
100
+ end
101
+
102
+ def attach(existing, digest)
103
+ raise RunConflictError, conflict_message(existing) if existing.digest != digest
104
+
105
+ Reservation.new(record: existing, status: Reservation::ATTACHED)
106
+ end
107
+
108
+ def records_for(scope, thread_id)
109
+ thread = @threads[thread_key(scope, thread_id)] || []
110
+ thread.filter_map { |run_id| @records[key_for(scope, thread_id, run_id)] }
111
+ end
112
+
113
+ def key_for(scope, thread_id, run_id)
114
+ [scope_key(scope), thread_id, run_id]
115
+ end
116
+
117
+ def thread_key(scope, thread_id)
118
+ [scope_key(scope), thread_id]
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Shared shape checks for untrusted JSON.
6
+ #
7
+ # Every method raises an InputError subclass whose message names the
8
+ # offending member but never quotes the value, so a host can return it to
9
+ # the client without echoing request bytes back.
10
+ #
11
+ # Included by RubyLLM::AgUI::Input::Parser and RubyLLM::AgUI::MessageParser.
12
+ module Validation
13
+ # The longest accepted id (threadId, runId, message id, interrupt id).
14
+ MAX_ID_LENGTH = 255
15
+
16
+ # The longest accepted free-text string.
17
+ MAX_CONTENT_LENGTH = 1_000_000
18
+
19
+ private
20
+
21
+ def identifier!(value, name, required: true)
22
+ return nil if value.nil? && !required
23
+
24
+ raise InvalidInputError, "#{name} is required." if value.nil?
25
+ raise InvalidInputError, "#{name} must be a string." unless value.is_a?(String)
26
+ raise InvalidInputError, "#{name} must not be empty." if value.empty?
27
+ raise InvalidInputError, "#{name} is too long." if value.length > MAX_ID_LENGTH
28
+
29
+ value
30
+ end
31
+
32
+ def string!(value, name, max: MAX_CONTENT_LENGTH)
33
+ raise InvalidInputError, "#{name} is required." if value.nil?
34
+ raise InvalidInputError, "#{name} must be a string." unless value.is_a?(String)
35
+ raise InvalidInputError, "#{name} is too long." if value.length > max
36
+
37
+ value
38
+ end
39
+
40
+ def optional_string(value, name)
41
+ return nil if value.nil?
42
+
43
+ string!(value, name)
44
+ end
45
+
46
+ def array!(value, name, required: false, max: nil)
47
+ return [] if value.nil? && !required
48
+
49
+ raise InvalidInputError, "#{name} is required." if value.nil?
50
+ raise InvalidInputError, "#{name} must be an array." unless value.is_a?(Array)
51
+ raise InputTooLargeError, "#{name} exceeds the #{max} entry limit." if max && value.size > max
52
+
53
+ value
54
+ end
55
+
56
+ def object!(value, name)
57
+ raise InvalidInputError, "#{name} must be an object." unless value.is_a?(Hash)
58
+
59
+ value
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The version of the ruby_llm-ag_ui gem, as a string.
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'ag_ui_protocol'
5
+ require 'zeitwerk'
6
+ require 'json'
7
+ require 'digest'
8
+ require 'securerandom'
9
+ require 'time'
10
+
11
+ require_relative 'ag_ui/version'
12
+
13
+ # RubyLLM::AgUI is an unofficial RubyLLM ecosystem extension that runs one
14
+ # RubyLLM 2.x chat turn as one AG-UI 1.0 run. Require it with:
15
+ #
16
+ # require 'ruby_llm/ag_ui'
17
+ #
18
+ # The core is pure Ruby. Requiring this file does not load Rails, open a
19
+ # network connection, or read provider credentials. The Rails adapter lives
20
+ # under RubyLLM::AgUI::Rails and loads only when Rails is present.
21
+ module RubyLLM
22
+ # The AG-UI integration namespace. Inside this namespace the protocol SDK is
23
+ # referred to as ::AgUiProtocol and the Rails framework as ::Rails, because
24
+ # RubyLLM::AgUI::Rails would otherwise shadow them.
25
+ module AgUI
26
+ class << self
27
+ # Yields the memoized configuration.
28
+ #
29
+ # RubyLLM::AgUI.configure do |config|
30
+ # config.run_store = MyRunStore.new
31
+ # end
32
+ def configure
33
+ yield config
34
+ end
35
+
36
+ # The memoized RubyLLM::AgUI::Configuration.
37
+ def config
38
+ @config ||= Configuration.new
39
+ end
40
+
41
+ # Replaces the configuration with defaults. Intended for tests.
42
+ def reset_configuration!
43
+ @config = Configuration.new
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ loader = Zeitwerk::Loader.new
50
+ loader.tag = 'ruby_llm-ag_ui'
51
+ # Zeitwerk inflections match a whole basename, so each entry names a file:
52
+ # sse_writer.rb -> SSEWriter. Acronyms inside a longer name (ui, api) are not
53
+ # inflected and would need their own entry.
54
+ loader.inflector.inflect('sse_writer' => 'SSEWriter')
55
+ loader.push_dir(File.expand_path('ag_ui', __dir__), namespace: RubyLLM::AgUI)
56
+ # Errors are part of the boot contract: hosts rescue them without a lazy load.
57
+ loader.ignore(File.expand_path('ag_ui/error.rb', __dir__))
58
+ # The Rails adapter is not part of the pure-Ruby core. It is loaded by its
59
+ # railtie when Rails is present, or explicitly by the host.
60
+ loader.ignore(File.expand_path('ag_ui/rails', __dir__))
61
+ loader.setup
62
+
63
+ require_relative 'ag_ui/error'
64
+
65
+ require_relative 'ag_ui/rails/railtie' if defined?(Rails::Railtie)