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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# The result of redeeming an interrupt.
|
|
6
|
+
#
|
|
7
|
+
# A caller cannot tell a fresh decision from a replay by the interrupt
|
|
8
|
+
# alone: both come back approved. The distinction is exactly what decides
|
|
9
|
+
# whether the tool may run, so the store returns it explicitly. Only the
|
|
10
|
+
# call that recorded the decision may execute the tool; a replay reports the
|
|
11
|
+
# answer already given and must not run anything again.
|
|
12
|
+
class Redemption
|
|
13
|
+
# The stored interrupt, with its decision.
|
|
14
|
+
attr_reader :interrupt
|
|
15
|
+
|
|
16
|
+
def initialize(interrupt:, replayed:)
|
|
17
|
+
@interrupt = interrupt
|
|
18
|
+
@replayed = replayed
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Whether this call recorded the decision.
|
|
23
|
+
def recorded?
|
|
24
|
+
!@replayed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Whether the decision was already recorded by an earlier call.
|
|
28
|
+
def replayed?
|
|
29
|
+
@replayed
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The interrupt's status: :approved, :denied or :cancelled.
|
|
33
|
+
def status
|
|
34
|
+
interrupt.status
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether the stored decision is an approval.
|
|
38
|
+
def approved?
|
|
39
|
+
interrupt.approved?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the stored decision is a refusal.
|
|
43
|
+
def denied?
|
|
44
|
+
interrupt.denied?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the stored decision abandons the interrupt.
|
|
48
|
+
def cancelled?
|
|
49
|
+
interrupt.cancelled?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The tool call the decision concerns.
|
|
53
|
+
def tool_call_id
|
|
54
|
+
interrupt.tool_call_id
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{ 'interruptId' => interrupt.id, 'status' => status.to_s, 'replayed' => replayed? }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# The answer to a run reservation: the record that owns the identity, and
|
|
6
|
+
# how the reservation was obtained.
|
|
7
|
+
#
|
|
8
|
+
# A retried request whose input digest matches an existing reservation gets
|
|
9
|
+
# ATTACHED, so the host streams or reports the run already in progress
|
|
10
|
+
# instead of executing it a second time.
|
|
11
|
+
class Reservation
|
|
12
|
+
# The identity was free and is now claimed by this call.
|
|
13
|
+
CREATED = :created
|
|
14
|
+
# The identity was already claimed by an identical request.
|
|
15
|
+
ATTACHED = :attached
|
|
16
|
+
|
|
17
|
+
# The record that owns the run identity.
|
|
18
|
+
attr_reader :record
|
|
19
|
+
|
|
20
|
+
# :created or :attached.
|
|
21
|
+
attr_reader :status
|
|
22
|
+
|
|
23
|
+
def initialize(record:, status:)
|
|
24
|
+
@record = record
|
|
25
|
+
@status = status
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether this call claimed the identity.
|
|
30
|
+
def created?
|
|
31
|
+
status == CREATED
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the identity was already claimed by an identical request.
|
|
35
|
+
def attached?
|
|
36
|
+
status == ATTACHED
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def thread_id
|
|
40
|
+
record.thread_id
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run_id
|
|
44
|
+
record.run_id
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def digest
|
|
48
|
+
record.digest
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{ 'status' => status.to_s, 'threadId' => thread_id, 'runId' => run_id, 'digest' => digest }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# The answers a run's input gives to a previous run's interrupts (FR-06).
|
|
6
|
+
#
|
|
7
|
+
# A resume is a request, never a permission grant. Before anything is
|
|
8
|
+
# written, every entry is checked against the live state: it must name an
|
|
9
|
+
# interrupt of the *most recent* interrupted run on the same thread, the
|
|
10
|
+
# tool call it concerns must still be pending on the host's chat, and that
|
|
11
|
+
# call's arguments must still digest to what the interrupt recorded, so a
|
|
12
|
+
# resume cannot change what it approves. The decision is then recorded once;
|
|
13
|
+
# a repeated resume is refused rather than executed again, because the tool
|
|
14
|
+
# may already have run.
|
|
15
|
+
#
|
|
16
|
+
# An approval answers an interrupt. The host still re-authorizes the actor
|
|
17
|
+
# before the tool executes.
|
|
18
|
+
class Resume
|
|
19
|
+
# One entry's effect: the interrupt it answers and the decision to record.
|
|
20
|
+
Decision = Struct.new(:interrupt, :decision, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
def initialize(input:, store:, scope:, chat:)
|
|
23
|
+
@input = input
|
|
24
|
+
@store = store
|
|
25
|
+
@scope = scope
|
|
26
|
+
@chat = chat
|
|
27
|
+
@plan = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether the input answers anything at all.
|
|
31
|
+
def needed?
|
|
32
|
+
@input.resume?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Checks every entry and builds the plan. Writes nothing, so a rejected
|
|
36
|
+
# resume is rejected before the run opens a stream.
|
|
37
|
+
def plan(now:)
|
|
38
|
+
source = interrupted_run
|
|
39
|
+
@plan = @input.resume.map { |entry| plan_entry(source, entry, now) }
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Records each planned decision once and tells the chat about it, so the
|
|
44
|
+
# next completion runs an approved tool or refuses a denied one.
|
|
45
|
+
def apply(now:)
|
|
46
|
+
@plan.each { |planned| redeem(planned, now: now) }
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Only the most recent interrupted run on the thread is resumable.
|
|
53
|
+
# Anything older is stale, and answering it would apply a decision to a
|
|
54
|
+
# conversation that has already moved on.
|
|
55
|
+
def interrupted_run
|
|
56
|
+
record = @store.latest_interrupted(scope: @scope, thread_id: @input.thread_id)
|
|
57
|
+
raise ResumeRejectedError, "thread #{@input.thread_id} has no interrupted run to resume" if record.nil?
|
|
58
|
+
|
|
59
|
+
record
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def plan_entry(source, entry, now)
|
|
63
|
+
interrupt = source.interrupt(entry.interrupt_id)
|
|
64
|
+
raise ResumeRejectedError, "interrupt #{entry.interrupt_id} is not the latest run's" if interrupt.nil?
|
|
65
|
+
raise ResumeRejectedError, "interrupt #{interrupt.id} has already been answered" unless interrupt.pending?
|
|
66
|
+
raise ResumeRejectedError, "interrupt #{interrupt.id} has expired" if interrupt.expired?(now)
|
|
67
|
+
|
|
68
|
+
ensure_unchanged!(interrupt)
|
|
69
|
+
Decision.new(interrupt: interrupt, decision: decision_for(entry))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The call an interrupt approves must still be pending on the host's chat
|
|
73
|
+
# with the same arguments. A resume answers the question that was asked; it
|
|
74
|
+
# cannot change it.
|
|
75
|
+
def ensure_unchanged!(interrupt)
|
|
76
|
+
tool_call = pending_approval(interrupt.tool_call_id)
|
|
77
|
+
raise ResumeRejectedError, "tool call #{interrupt.tool_call_id} is not pending on this chat" unless tool_call
|
|
78
|
+
|
|
79
|
+
return if Canonical.digest(tool_call.arguments) == interrupt.arguments_digest
|
|
80
|
+
|
|
81
|
+
raise ResumeRejectedError, "arguments for tool call #{interrupt.tool_call_id} changed since the interrupt"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The store's redemption is atomic: the first answer record wins, and a
|
|
85
|
+
# repeat comes back as a replay that must not execute anything.
|
|
86
|
+
def redeem(planned, now:)
|
|
87
|
+
redemption = @store.redeem_interrupt(
|
|
88
|
+
scope: @scope, thread_id: @input.thread_id,
|
|
89
|
+
interrupt_id: planned.interrupt.id, decision: planned.decision, now: now
|
|
90
|
+
)
|
|
91
|
+
raise ResumeRejectedError, "interrupt #{planned.interrupt.id} was already answered" if redemption.replayed?
|
|
92
|
+
|
|
93
|
+
redemption.approved? ? @chat.approve(redemption.tool_call_id) : @chat.deny(redemption.tool_call_id)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The protocol's resume vocabulary is resolved or cancelled; the interrupt
|
|
97
|
+
# records the truthful decision, and a cancelled answer denies the call
|
|
98
|
+
# rather than approving it.
|
|
99
|
+
def decision_for(entry)
|
|
100
|
+
entry.resolved? ? Interrupt::APPROVED : Interrupt::CANCELLED
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def pending_approval(tool_call_id)
|
|
104
|
+
@chat.pending_approvals.find { |tool_call| tool_call.id == tool_call_id }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# One answer to one interrupt, as the client sends it on the run that
|
|
6
|
+
# continues from an interrupted one.
|
|
7
|
+
#
|
|
8
|
+
# A resume entry is a request, never a permission grant: the run still
|
|
9
|
+
# checks that the interrupt exists, belongs to the most recent interrupted
|
|
10
|
+
# run on this thread, has not been redeemed, and has not expired, and the
|
|
11
|
+
# host still re-authorizes before any tool runs.
|
|
12
|
+
class ResumeEntry
|
|
13
|
+
# The protocol's two statuses.
|
|
14
|
+
RESOLVED = 'resolved'
|
|
15
|
+
CANCELLED = 'cancelled'
|
|
16
|
+
STATUSES = [RESOLVED, CANCELLED].freeze
|
|
17
|
+
|
|
18
|
+
# Identifies the interrupt this entry answers.
|
|
19
|
+
attr_reader :interrupt_id
|
|
20
|
+
|
|
21
|
+
# "resolved" or "cancelled".
|
|
22
|
+
attr_reader :status
|
|
23
|
+
|
|
24
|
+
# The answer the client supplies, for an interrupt that asked for one.
|
|
25
|
+
attr_reader :payload
|
|
26
|
+
|
|
27
|
+
def initialize(interrupt_id:, status:, payload: nil)
|
|
28
|
+
@interrupt_id = interrupt_id
|
|
29
|
+
@status = status
|
|
30
|
+
@payload = payload
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the client answered the interrupt.
|
|
35
|
+
def resolved?
|
|
36
|
+
status == RESOLVED
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Whether the client abandoned the interrupt.
|
|
40
|
+
def cancelled?
|
|
41
|
+
status == CANCELLED
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
{ 'interruptId' => interrupt_id, 'status' => status, 'payload' => payload }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# Runs one RubyLLM 2.x chat turn as one AG-UI 1.0 run.
|
|
6
|
+
#
|
|
7
|
+
# input = RubyLLM::AgUI::Input.parse(request_body)
|
|
8
|
+
# chat = host_chat_for(actor, input.thread_id) # host-owned RubyLLM::Chat
|
|
9
|
+
# RubyLLM::AgUI::Run.new(chat:, input:, scope: "tenant:#{actor.id}").stream(to: response.stream)
|
|
10
|
+
#
|
|
11
|
+
# The host owns authentication, authorization, the chat, the tools a run may
|
|
12
|
+
# use and the transport. This class owns the run's identity (reserved before
|
|
13
|
+
# any provider work), the ordering of the events, and the outcome: a
|
|
14
|
+
# cancelled run is never reported as success, a provider failure is a
|
|
15
|
+
# sanitized RUN_ERROR, a paused run is an interrupt rather than a success,
|
|
16
|
+
# and an identity that is already reserved is never executed a second time.
|
|
17
|
+
#
|
|
18
|
+
# +scope+ is the host's tenant/actor boundary, built from authenticated
|
|
19
|
+
# identity. The gem takes it from the host rather than deriving it from the
|
|
20
|
+
# input, because a scope derived from anything the client sent would let one
|
|
21
|
+
# client attach to another's run.
|
|
22
|
+
class Run
|
|
23
|
+
# The run's authoritative record, once it has been reserved.
|
|
24
|
+
attr_reader :record
|
|
25
|
+
|
|
26
|
+
# rubocop:disable-next Metrics/ParameterLists -- one keyword per host-supplied or overridable value
|
|
27
|
+
def initialize(chat:, input:, scope:, store: nil, logger: nil, redact: nil, clock: nil, expires_in: nil,
|
|
28
|
+
state: nil)
|
|
29
|
+
@chat = chat
|
|
30
|
+
@input = input
|
|
31
|
+
@scope = scope
|
|
32
|
+
@store = store || RubyLLM::AgUI.config.run_store!
|
|
33
|
+
@logger = logger || RubyLLM::AgUI.config.logger
|
|
34
|
+
@redact = redact
|
|
35
|
+
@clock = clock || RubyLLM::AgUI.config.clock
|
|
36
|
+
@expires_in = expires_in || RubyLLM::AgUI.config.interrupt_ttl
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
validate!
|
|
39
|
+
@state = state.nil? ? @input.state : state
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reserves the run and streams it to +to+, an IO-like stream. Returns the
|
|
43
|
+
# run's record, which carries the status the run ended with.
|
|
44
|
+
#
|
|
45
|
+
# The reservation happens before any provider work: a request whose input
|
|
46
|
+
# digest matches an identity already reserved attaches instead of
|
|
47
|
+
# executing, and a changed input under the same key is refused by the
|
|
48
|
+
# store. A reserved run that already ended is served as a reconnect — its
|
|
49
|
+
# outcome and state snapshot — never as a second execution.
|
|
50
|
+
def stream(to:)
|
|
51
|
+
resume.plan(now: @clock.call) if resume.needed?
|
|
52
|
+
reservation = reserve
|
|
53
|
+
return execute(reservation.record, to) if reservation.created?
|
|
54
|
+
|
|
55
|
+
reconnect(reservation.record, to)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def resume
|
|
61
|
+
@resume ||= Resume.new(input: @input, store: @store, scope: @scope, chat: @chat)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reserve
|
|
65
|
+
@store.reserve(
|
|
66
|
+
scope: @scope, thread_id: @input.thread_id, run_id: @input.run_id, digest: @input.digest
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# An identity that is already reserved is never executed a second time.
|
|
71
|
+
# A run that ended is served as a reconnect: its outcome and state
|
|
72
|
+
# snapshot, without replaying the deltas or running anything again.
|
|
73
|
+
#
|
|
74
|
+
# A run that has not ended cannot be served here. A second stream cannot
|
|
75
|
+
# join a live one without a relay the gem does not own, and pretending it
|
|
76
|
+
# did would either hang or duplicate the work, so it fails closed.
|
|
77
|
+
def reconnect(record, to)
|
|
78
|
+
unless Reconnect.serves?(record)
|
|
79
|
+
raise StreamError, "run #{record.run_id} on thread #{record.thread_id} is still #{record.status}; " \
|
|
80
|
+
'a live run cannot be joined by a second stream'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
build_emitter(record, to).replay(Reconnect.new(record: record, input: @input).events)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execute(record, to)
|
|
87
|
+
@mapper = build_mapper(record)
|
|
88
|
+
@emitter = build_emitter(record, to, mapper: @mapper)
|
|
89
|
+
@emitter.start(run_started_event(record), state: @state)
|
|
90
|
+
resume.apply(now: @clock.call) if resume.needed?
|
|
91
|
+
drive
|
|
92
|
+
@record = finish
|
|
93
|
+
rescue RubyLLM::CancelledError
|
|
94
|
+
@record = @emitter.cancel
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
@record = @emitter.fail(e)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_emitter(record, to, mapper: build_mapper(record))
|
|
100
|
+
Emitter.new(
|
|
101
|
+
writer: SSEWriter.new(to, logger: @logger), mapper: mapper,
|
|
102
|
+
store: @store, record: record, clock: @clock, logger: @logger
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Drives RubyLLM and projects what it reports.
|
|
107
|
+
#
|
|
108
|
+
# RubyLLM runs a host's local tools concurrently when asked to
|
|
109
|
+
# (`tool_concurrency`), so `before_tool_call` can arrive from several
|
|
110
|
+
# threads at once. The mapper is per-run state, so mapping and writing
|
|
111
|
+
# happen under one lock: two callbacks never interleave inside it, and the
|
|
112
|
+
# run's sequence stays a single ordered list.
|
|
113
|
+
def drive
|
|
114
|
+
@chat.before_tool_call { |tool_call| project { @mapper.before_tool_call(tool_call) } }
|
|
115
|
+
@chat.after_message { |message| project { @mapper.message(message) } }
|
|
116
|
+
@chat.complete { |chunk| project { @mapper.chunk(chunk) } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def project
|
|
120
|
+
@mutex.synchronize { @emitter.write(yield) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A run that stopped for a human is an interrupt, never a success. Any
|
|
124
|
+
# other pause is something this producer cannot express on the wire, so it
|
|
125
|
+
# is a failure with a fixed, safe message rather than a run reported as
|
|
126
|
+
# finished.
|
|
127
|
+
def finish
|
|
128
|
+
return @emitter.succeed unless @chat.waiting?
|
|
129
|
+
raise UnsupportedRunStateError unless @chat.awaiting_approval?
|
|
130
|
+
|
|
131
|
+
@emitter.interrupt(build_interrupts)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# One interrupt per pending approval, carrying the tool call, the digest of
|
|
135
|
+
# the arguments RubyLLM asked to run, and a host-owned expiry. The host
|
|
136
|
+
# still re-authorizes before the tool executes: an approval answers an
|
|
137
|
+
# interrupt, it never grants a permission.
|
|
138
|
+
def build_interrupts
|
|
139
|
+
expires_at = @clock.call + @expires_in
|
|
140
|
+
@chat.pending_approvals.map do |tool_call|
|
|
141
|
+
Interrupt.create(
|
|
142
|
+
tool_call_id: tool_call.id,
|
|
143
|
+
arguments_digest: Canonical.digest(tool_call.arguments),
|
|
144
|
+
expires_at: expires_at,
|
|
145
|
+
message: "Approve the #{tool_call.name} tool call?"
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_mapper(record)
|
|
151
|
+
Mapper.new(thread_id: record.thread_id, run_id: record.run_id, redact: @redact, logger: @logger)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def run_started_event(record)
|
|
155
|
+
Protocol.run_started(
|
|
156
|
+
thread_id: record.thread_id, run_id: record.run_id,
|
|
157
|
+
parent_run_id: @input.parent_run_id, input: @input.to_h
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate!
|
|
162
|
+
unless @chat.respond_to?(:complete)
|
|
163
|
+
raise ConfigurationError, 'chat must be a RubyLLM::Chat or act like one (it does not respond to #complete)'
|
|
164
|
+
end
|
|
165
|
+
raise ConfigurationError, 'input must be a RubyLLM::AgUI::Input' unless @input.is_a?(Input)
|
|
166
|
+
|
|
167
|
+
validate_scope!
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_scope!
|
|
171
|
+
return if @scope.is_a?(String) && !@scope.empty?
|
|
172
|
+
|
|
173
|
+
raise ConfigurationError, 'scope must be a non-empty String built from authenticated identity'
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# The authoritative server-side progress of one reserved run.
|
|
6
|
+
#
|
|
7
|
+
# The record is immutable: every transition returns a new record that the
|
|
8
|
+
# store persists. That keeps a run's identity, status and progress honest
|
|
9
|
+
# across processes, and it is what makes "a cancelled run is never reported
|
|
10
|
+
# as success" checkable rather than a comment: once a record reaches a
|
|
11
|
+
# terminal status, no transition can move it to a different one.
|
|
12
|
+
#
|
|
13
|
+
# A record is host bookkeeping, not business truth. The host still owns the
|
|
14
|
+
# conversation, the messages and whatever the tool calls actually did.
|
|
15
|
+
class RunRecord
|
|
16
|
+
# The statuses a run moves through: identity is claimed (RESERVED), the run
|
|
17
|
+
# streams (STARTED), and it ends in exactly one of the terminal statuses.
|
|
18
|
+
RESERVED = :reserved
|
|
19
|
+
STARTED = :started
|
|
20
|
+
# The run ended normally.
|
|
21
|
+
SUCCEEDED = :succeeded
|
|
22
|
+
# The run paused for human input.
|
|
23
|
+
INTERRUPTED = :interrupted
|
|
24
|
+
# The run was stopped before it completed and did not fail.
|
|
25
|
+
CANCELLED = :cancelled
|
|
26
|
+
# The run failed.
|
|
27
|
+
FAILED = :failed
|
|
28
|
+
|
|
29
|
+
STATUSES = [RESERVED, STARTED, SUCCEEDED, INTERRUPTED, CANCELLED, FAILED].freeze
|
|
30
|
+
|
|
31
|
+
# The statuses a run can end in. A run has exactly one.
|
|
32
|
+
TERMINAL_STATUSES = [SUCCEEDED, INTERRUPTED, CANCELLED, FAILED].freeze
|
|
33
|
+
|
|
34
|
+
# The host's tenant/actor/thread scope. Part of every reservation key.
|
|
35
|
+
attr_reader :scope
|
|
36
|
+
|
|
37
|
+
# The AG-UI conversation this run belongs to.
|
|
38
|
+
attr_reader :thread_id
|
|
39
|
+
|
|
40
|
+
# Identifies this run. Unique within (scope, thread_id).
|
|
41
|
+
attr_reader :run_id
|
|
42
|
+
|
|
43
|
+
# The digest of the input this run was reserved for. A retry that sends
|
|
44
|
+
# the same input attaches to this record instead of running twice; a
|
|
45
|
+
# different input under the same key is a conflict.
|
|
46
|
+
attr_reader :digest
|
|
47
|
+
|
|
48
|
+
# The run's current status.
|
|
49
|
+
attr_reader :status
|
|
50
|
+
|
|
51
|
+
# The highest event sequence number handed out for this run. Snapshots on
|
|
52
|
+
# reconnect are ordered by it rather than by wall-clock time.
|
|
53
|
+
attr_reader :sequence
|
|
54
|
+
|
|
55
|
+
# The host-owned run state kept for a reconnect snapshot, or nil.
|
|
56
|
+
attr_reader :snapshot
|
|
57
|
+
|
|
58
|
+
# The sanitized failure a reconnect reports for a run that failed, as
|
|
59
|
+
# `{"message" =>, "code" =>}`, or nil. Never the exception itself: a
|
|
60
|
+
# reconnect must not re-leak what the original RUN_ERROR withheld.
|
|
61
|
+
attr_reader :failure
|
|
62
|
+
|
|
63
|
+
# The interrupts raised by this run, oldest first.
|
|
64
|
+
attr_reader :interrupts
|
|
65
|
+
|
|
66
|
+
attr_reader :created_at, :updated_at
|
|
67
|
+
|
|
68
|
+
# rubocop:disable-next Metrics/ParameterLists -- one keyword per field the record owns
|
|
69
|
+
def initialize(scope:, thread_id:, run_id:, digest:, created_at:, status: RESERVED, sequence: 0,
|
|
70
|
+
snapshot: nil, interrupts: [], failure: nil, updated_at: nil)
|
|
71
|
+
@scope = scope
|
|
72
|
+
@thread_id = thread_id
|
|
73
|
+
@run_id = run_id
|
|
74
|
+
@digest = digest
|
|
75
|
+
@status = assert_status!(status)
|
|
76
|
+
@sequence = sequence
|
|
77
|
+
@snapshot = snapshot
|
|
78
|
+
@interrupts = interrupts.freeze
|
|
79
|
+
@failure = failure
|
|
80
|
+
@created_at = created_at
|
|
81
|
+
@updated_at = updated_at || created_at
|
|
82
|
+
freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# One predicate per status, so callers read `record.interrupted?` rather
|
|
86
|
+
# than comparing symbols.
|
|
87
|
+
STATUSES.each do |status|
|
|
88
|
+
define_method(:"#{status}?") { self.status == status }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether the run has ended.
|
|
92
|
+
def terminal?
|
|
93
|
+
TERMINAL_STATUSES.include?(status)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Whether the run is waiting on a human. Reported as an interrupt outcome;
|
|
97
|
+
# never reported as success.
|
|
98
|
+
def awaiting_approval?
|
|
99
|
+
interrupted? && interrupts.any?(&:pending?)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The interrupts that still wait for an answer.
|
|
103
|
+
def pending_interrupts
|
|
104
|
+
interrupts.select(&:pending?)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The interrupt with +id+, or nil.
|
|
108
|
+
def interrupt(id)
|
|
109
|
+
interrupts.find { |interrupt| interrupt.id == id }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Moves a reserved run to :started, remembering the host-owned state a
|
|
113
|
+
# reconnect serves. Idempotent for an already-started run.
|
|
114
|
+
def start(now:, snapshot: nil)
|
|
115
|
+
return self if started?
|
|
116
|
+
|
|
117
|
+
transition_to(STARTED, now: now, snapshot: snapshot)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Hands out the next event sequence number.
|
|
121
|
+
def advance(now:)
|
|
122
|
+
with(now: now, sequence: sequence + 1)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Closes the run with +status+: :succeeded, :interrupted, :cancelled or
|
|
126
|
+
# :failed, and, for a failure, the sanitized error a reconnect reports.
|
|
127
|
+
# Re-applying the same terminal status is an idempotent no-op — so the
|
|
128
|
+
# first failure wins rather than being replaced by a second, possibly
|
|
129
|
+
# different, explanation — and any other terminal status is a protocol
|
|
130
|
+
# violation, because a run has exactly one outcome.
|
|
131
|
+
def finish(status, now:, failure: nil)
|
|
132
|
+
raise ConfigurationError, "cannot finish a run as #{status.inspect}" unless TERMINAL_STATUSES.include?(status)
|
|
133
|
+
return self if terminal? && self.status == status
|
|
134
|
+
raise StreamError, "run #{run_id} already ended as #{self.status}, not #{status}" if terminal?
|
|
135
|
+
|
|
136
|
+
transition_to(status, now: now, failure: failure)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Closes the run as interrupted and records the interrupts it raised.
|
|
140
|
+
def interrupted(interrupts, now:)
|
|
141
|
+
raise ConfigurationError, 'an interrupted run needs at least one interrupt' if interrupts.empty?
|
|
142
|
+
|
|
143
|
+
pending = interrupts.reject(&:decided?)
|
|
144
|
+
raise ConfigurationError, 'an interrupted run needs at least one unresolved interrupt' if pending.empty?
|
|
145
|
+
|
|
146
|
+
transition_to(INTERRUPTED, now: now, interrupts: interrupts)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Replaces the interrupt with the same id, keeping its position.
|
|
150
|
+
#
|
|
151
|
+
# This is the one transition that also applies to a terminal record: an
|
|
152
|
+
# interrupted run is closed, and answering its interrupt records the
|
|
153
|
+
# decision on that closed record.
|
|
154
|
+
def replace_interrupt(decided, now:)
|
|
155
|
+
raise ConfigurationError, "no interrupt #{decided.id} on run #{run_id}" unless interrupt(decided.id)
|
|
156
|
+
|
|
157
|
+
replaced = interrupts.map { |existing| existing.id == decided.id ? decided : existing }
|
|
158
|
+
with(now: now, interrupts: replaced)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def to_h
|
|
162
|
+
{
|
|
163
|
+
'scope' => scope.to_s,
|
|
164
|
+
'threadId' => thread_id,
|
|
165
|
+
'runId' => run_id,
|
|
166
|
+
'digest' => digest,
|
|
167
|
+
'status' => status.to_s,
|
|
168
|
+
'sequence' => sequence,
|
|
169
|
+
'snapshot' => snapshot,
|
|
170
|
+
'failure' => failure,
|
|
171
|
+
'interrupts' => interrupts.map(&:to_h),
|
|
172
|
+
'createdAt' => iso8601(created_at),
|
|
173
|
+
'updatedAt' => iso8601(updated_at)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The record in the form a durable store keeps. #to_h is the reporting
|
|
178
|
+
# shape; it leaves out what an interrupt needs to be re-verified, so a
|
|
179
|
+
# store round-trips this instead.
|
|
180
|
+
def to_storage
|
|
181
|
+
to_h.merge('interrupts' => interrupts.map(&:to_storage))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Rebuilds a record stored by #to_storage.
|
|
185
|
+
def self.from_storage(hash)
|
|
186
|
+
new(
|
|
187
|
+
scope: hash.fetch('scope'), thread_id: hash.fetch('threadId'), run_id: hash.fetch('runId'),
|
|
188
|
+
digest: hash.fetch('digest'), status: hash.fetch('status').to_sym,
|
|
189
|
+
sequence: hash.fetch('sequence'), snapshot: hash['snapshot'], failure: hash['failure'],
|
|
190
|
+
interrupts: interrupts_from_storage(hash['interrupts']),
|
|
191
|
+
created_at: Interrupt.parse_time(hash.fetch('createdAt')),
|
|
192
|
+
updated_at: Interrupt.parse_time(hash.fetch('updatedAt'))
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.interrupts_from_storage(list)
|
|
197
|
+
(list || []).map { |interrupt| Interrupt.from_storage(interrupt) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
# rubocop:disable-next Metrics/ParameterLists -- one keyword per field, each defaulted to the record's own
|
|
203
|
+
def with(now:, status: self.status, sequence: self.sequence, snapshot: self.snapshot,
|
|
204
|
+
interrupts: self.interrupts, failure: self.failure)
|
|
205
|
+
self.class.new(
|
|
206
|
+
scope: scope, thread_id: thread_id, run_id: run_id, digest: digest, status: status,
|
|
207
|
+
sequence: sequence, snapshot: snapshot, interrupts: interrupts, failure: failure,
|
|
208
|
+
created_at: created_at, updated_at: now
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def transition_to(status, now:, **attributes)
|
|
213
|
+
raise StreamError, "run #{run_id} already ended as #{self.status}" if terminal?
|
|
214
|
+
|
|
215
|
+
with(now: now, status: status, **attributes)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def iso8601(time)
|
|
219
|
+
time&.utc&.iso8601
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def assert_status!(value)
|
|
223
|
+
return value if STATUSES.include?(value)
|
|
224
|
+
|
|
225
|
+
raise ConfigurationError, "status must be one of #{STATUSES.join(', ')}"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|