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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +48 -0
- data/CONTRIBUTING.md +56 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/SECURITY.md +30 -0
- data/docs/_advanced/rails-integration.md +199 -0
- data/docs/_advanced/security-and-deployment.md +204 -0
- data/docs/_core_features/approval-and-resume.md +62 -0
- data/docs/_core_features/input-validation.md +64 -0
- data/docs/_core_features/reconnects.md +42 -0
- data/docs/_core_features/streaming-runs.md +49 -0
- data/docs/_getting_started/configuration.md +57 -0
- data/docs/_getting_started/getting-started.md +52 -0
- data/docs/_getting_started/overview.md +189 -0
- data/docs/_getting_started/whats-new-in-0-1.md +41 -0
- data/docs/_reference/api-reference.md +26 -0
- data/docs/_reference/compatibility.md +130 -0
- data/docs/_reference/protocol-coverage.md +175 -0
- data/docs/_reference/release-history.md +22 -0
- data/docs/_reference/releasing.md +54 -0
- data/docs/_reference/third-party-notices.md +82 -0
- data/docs/_reference/upgrading.md +42 -0
- data/docs/index.md +53 -0
- data/lib/generators/ruby_llm/ag_ui/install_generator.rb +37 -0
- data/lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt +24 -0
- data/lib/generators/ruby_llm/ag_ui/templates/initializer.rb.tt +24 -0
- data/lib/ruby_llm/ag_ui/canonical.rb +40 -0
- data/lib/ruby_llm/ag_ui/configuration.rb +113 -0
- data/lib/ruby_llm/ag_ui/coverage.rb +163 -0
- data/lib/ruby_llm/ag_ui/emitter.rb +119 -0
- data/lib/ruby_llm/ag_ui/error.rb +69 -0
- data/lib/ruby_llm/ag_ui/error_sanitizer.rb +69 -0
- data/lib/ruby_llm/ag_ui/input.rb +299 -0
- data/lib/ruby_llm/ag_ui/interrupt.rb +207 -0
- data/lib/ruby_llm/ag_ui/mapper.rb +333 -0
- data/lib/ruby_llm/ag_ui/message_parser.rb +127 -0
- data/lib/ruby_llm/ag_ui/protocol.rb +130 -0
- data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +119 -0
- data/lib/ruby_llm/ag_ui/rails/railtie.rb +29 -0
- data/lib/ruby_llm/ag_ui/rails/record.rb +51 -0
- data/lib/ruby_llm/ag_ui/rails/streaming.rb +166 -0
- data/lib/ruby_llm/ag_ui/reconnect.rb +86 -0
- data/lib/ruby_llm/ag_ui/redaction.rb +58 -0
- data/lib/ruby_llm/ag_ui/redemption.rb +62 -0
- data/lib/ruby_llm/ag_ui/reservation.rb +56 -0
- data/lib/ruby_llm/ag_ui/resume.rb +108 -0
- data/lib/ruby_llm/ag_ui/resume_entry.rb +49 -0
- data/lib/ruby_llm/ag_ui/run.rb +177 -0
- data/lib/ruby_llm/ag_ui/run_record.rb +229 -0
- data/lib/ruby_llm/ag_ui/sse_writer.rb +119 -0
- data/lib/ruby_llm/ag_ui/store/base.rb +123 -0
- data/lib/ruby_llm/ag_ui/store/memory_store.rb +123 -0
- data/lib/ruby_llm/ag_ui/validation.rb +63 -0
- data/lib/ruby_llm/ag_ui/version.rb +8 -0
- data/lib/ruby_llm/ag_ui.rb +65 -0
- 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,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)
|