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,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
|