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,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# One approval request attached to a reserved run, and the answer to it.
|
|
6
|
+
#
|
|
7
|
+
# A RubyLLM `requires_approval` pause becomes one interrupt per pending tool
|
|
8
|
+
# call. The interrupt is the only thing that ties a client resume entry to a
|
|
9
|
+
# tool call, and it is deliberately narrow:
|
|
10
|
+
#
|
|
11
|
+
# * it names the exact `toolCallId` and the digest of the arguments RubyLLM
|
|
12
|
+
# asked to run, so a resume that tries to change the arguments cannot pass
|
|
13
|
+
# as the same request;
|
|
14
|
+
# * it carries a host-owned expiry, so an old approval does not stay valid
|
|
15
|
+
# forever;
|
|
16
|
+
# * it records exactly one decision, so a replayed resume entry gets the
|
|
17
|
+
# stored decision back instead of running the tool a second time.
|
|
18
|
+
#
|
|
19
|
+
# An interrupt is not a permission grant. The host re-authorizes the actor
|
|
20
|
+
# before the tool runs, whatever this record says.
|
|
21
|
+
class Interrupt
|
|
22
|
+
# No decision yet.
|
|
23
|
+
PENDING = :pending
|
|
24
|
+
# The actor approved the tool call.
|
|
25
|
+
APPROVED = :approved
|
|
26
|
+
# The actor declined the tool call.
|
|
27
|
+
DENIED = :denied
|
|
28
|
+
# The actor abandoned the interrupt.
|
|
29
|
+
CANCELLED = :cancelled
|
|
30
|
+
|
|
31
|
+
STATUSES = [PENDING, APPROVED, DENIED, CANCELLED].freeze
|
|
32
|
+
|
|
33
|
+
# The statuses that count as an answer.
|
|
34
|
+
DECISIONS = [APPROVED, DENIED, CANCELLED].freeze
|
|
35
|
+
|
|
36
|
+
# The protocol's reason for a tool approval.
|
|
37
|
+
REASON_TOOL_CALL = 'tool_call_confirmation'
|
|
38
|
+
|
|
39
|
+
# An id unique among the interrupts of one thread. A resume entry answers
|
|
40
|
+
# an interrupt by this id, so it must be unguessable enough that a client
|
|
41
|
+
# cannot answer someone else's approval.
|
|
42
|
+
attr_reader :id
|
|
43
|
+
|
|
44
|
+
# The RubyLLM tool call this interrupt concerns.
|
|
45
|
+
attr_reader :tool_call_id
|
|
46
|
+
|
|
47
|
+
# Why the run stopped. An open protocol string.
|
|
48
|
+
attr_reader :reason
|
|
49
|
+
|
|
50
|
+
# A human-readable prompt for whoever answers.
|
|
51
|
+
attr_reader :message
|
|
52
|
+
|
|
53
|
+
# The SHA-256 digest of the arguments RubyLLM asked to run. Compared
|
|
54
|
+
# before execution so changed arguments cannot reuse an approval.
|
|
55
|
+
attr_reader :arguments_digest
|
|
56
|
+
|
|
57
|
+
# When the interrupt stops being answerable.
|
|
58
|
+
attr_reader :expires_at
|
|
59
|
+
|
|
60
|
+
# :pending, :approved, :denied or :cancelled.
|
|
61
|
+
attr_reader :status
|
|
62
|
+
|
|
63
|
+
# When the decision was recorded, or nil while pending.
|
|
64
|
+
attr_reader :decided_at
|
|
65
|
+
|
|
66
|
+
# When the interrupt was created.
|
|
67
|
+
attr_reader :created_at
|
|
68
|
+
|
|
69
|
+
# rubocop:disable Metrics/ParameterLists -- one keyword per field the value object owns
|
|
70
|
+
def initialize(id:, tool_call_id:, arguments_digest:, expires_at:, reason: REASON_TOOL_CALL,
|
|
71
|
+
message: nil, status: PENDING, decided_at: nil, created_at: nil)
|
|
72
|
+
@id = id
|
|
73
|
+
@tool_call_id = tool_call_id
|
|
74
|
+
@arguments_digest = arguments_digest
|
|
75
|
+
@expires_at = expires_at
|
|
76
|
+
@reason = reason
|
|
77
|
+
@message = message
|
|
78
|
+
@status = assert_status!(status)
|
|
79
|
+
@decided_at = decided_at
|
|
80
|
+
@created_at = created_at
|
|
81
|
+
freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Builds an interrupt with an unguessable id.
|
|
85
|
+
def self.create(tool_call_id:, arguments_digest:, expires_at:, reason: REASON_TOOL_CALL, message: nil,
|
|
86
|
+
created_at: nil)
|
|
87
|
+
new(
|
|
88
|
+
id: SecureRandom.uuid, tool_call_id: tool_call_id, arguments_digest: arguments_digest,
|
|
89
|
+
expires_at: expires_at, reason: reason, message: message, created_at: created_at
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
# rubocop:enable Metrics/ParameterLists
|
|
93
|
+
|
|
94
|
+
# Whether the interrupt still waits for an answer.
|
|
95
|
+
def pending?
|
|
96
|
+
status == PENDING
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether a decision has been recorded.
|
|
100
|
+
def decided?
|
|
101
|
+
DECISIONS.include?(status)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether the actor approved the call.
|
|
105
|
+
def approved?
|
|
106
|
+
status == APPROVED
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether the actor declined the call.
|
|
110
|
+
def denied?
|
|
111
|
+
status == DENIED
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Whether the actor abandoned the interrupt.
|
|
115
|
+
def cancelled?
|
|
116
|
+
status == CANCELLED
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether the host-owned expiry has passed at +now+.
|
|
120
|
+
def expired?(now)
|
|
121
|
+
!expires_at.nil? && now >= expires_at
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Whether the interrupt can still be answered at +now+.
|
|
125
|
+
def answerable?(now)
|
|
126
|
+
pending? && !expired?(now)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns a copy carrying +decision+. Refuses to overwrite an existing
|
|
130
|
+
# decision: a second answer is a replay or a conflict, never a new grant.
|
|
131
|
+
def decide(decision, now:)
|
|
132
|
+
raise ConfigurationError, "decision must be one of #{DECISIONS.join(', ')}" unless DECISIONS.include?(decision)
|
|
133
|
+
raise StreamError, "interrupt #{id} was already #{status}" if decided?
|
|
134
|
+
|
|
135
|
+
self.class.new(
|
|
136
|
+
id: id, tool_call_id: tool_call_id, arguments_digest: arguments_digest, expires_at: expires_at,
|
|
137
|
+
reason: reason, message: message, status: decision, decided_at: now, created_at: created_at
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The protocol's Interrupt shape, ready for a RUN_FINISHED interrupt
|
|
142
|
+
# outcome. Only the fields the pinned schema defines are set.
|
|
143
|
+
def to_protocol
|
|
144
|
+
::AgUiProtocol::Core::Types::Interrupt.new(
|
|
145
|
+
id: id,
|
|
146
|
+
reason: reason,
|
|
147
|
+
message: message,
|
|
148
|
+
tool_call_id: tool_call_id,
|
|
149
|
+
expires_at: expires_at&.utc&.iso8601
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The interrupt as plain data, with camelCase keys. The arguments digest
|
|
154
|
+
# and the caller-supplied ids stay out of it: they are host bookkeeping.
|
|
155
|
+
def to_h
|
|
156
|
+
{
|
|
157
|
+
'id' => id,
|
|
158
|
+
'toolCallId' => tool_call_id,
|
|
159
|
+
'reason' => reason,
|
|
160
|
+
'message' => message,
|
|
161
|
+
'expiresAt' => iso8601(expires_at),
|
|
162
|
+
'status' => status.to_s,
|
|
163
|
+
'decidedAt' => iso8601(decided_at)
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The interrupt in the form a durable store keeps, which #to_h is not: a
|
|
168
|
+
# store that dropped the arguments digest could not re-verify that a
|
|
169
|
+
# resume answers the call it approved, and one that dropped the creation
|
|
170
|
+
# time could not order two interrupts on a thread.
|
|
171
|
+
def to_storage
|
|
172
|
+
to_h.merge(
|
|
173
|
+
'argumentsDigest' => arguments_digest,
|
|
174
|
+
'createdAt' => iso8601(created_at)
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Rebuilds an interrupt stored by #to_storage.
|
|
179
|
+
def self.from_storage(hash)
|
|
180
|
+
new(
|
|
181
|
+
id: hash.fetch('id'), tool_call_id: hash.fetch('toolCallId'),
|
|
182
|
+
arguments_digest: hash.fetch('argumentsDigest'), reason: hash.fetch('reason'),
|
|
183
|
+
message: hash['message'], expires_at: parse_time(hash['expiresAt']),
|
|
184
|
+
status: hash.fetch('status').to_sym, decided_at: parse_time(hash['decidedAt']),
|
|
185
|
+
created_at: parse_time(hash['createdAt'])
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The time +value+ names, or nil. Storage writes ISO 8601 strings.
|
|
190
|
+
def self.parse_time(value)
|
|
191
|
+
value.nil? ? nil : Time.iso8601(value)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def iso8601(time)
|
|
197
|
+
time&.utc&.iso8601
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def assert_status!(value)
|
|
201
|
+
return value if STATUSES.include?(value)
|
|
202
|
+
|
|
203
|
+
raise ConfigurationError, "status must be one of #{STATUSES.join(', ')}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# Projects one RubyLLM 2.x chat turn onto AG-UI 1.0 events (FR-03).
|
|
6
|
+
#
|
|
7
|
+
# The mapper is the only place that decides which AG-UI event a RubyLLM
|
|
8
|
+
# signal becomes. It is a state machine rather than a set of converters,
|
|
9
|
+
# because the protocol's rules are about state: a text message must be open
|
|
10
|
+
# before its content arrives, a tool call must be closed before its result,
|
|
11
|
+
# consecutive steps must not overlap, and everything must be closed before
|
|
12
|
+
# the run's terminal event.
|
|
13
|
+
#
|
|
14
|
+
# It maps what RubyLLM actually reported and nothing else. It never invents
|
|
15
|
+
# an event to fill a gap in its own output, and it never re-synthesises tool
|
|
16
|
+
# arguments that RubyLLM delivered as a stream: the deltas go out as
|
|
17
|
+
# received, in order.
|
|
18
|
+
#
|
|
19
|
+
# Every method returns the events to write, in order. The mapper writes
|
|
20
|
+
# nothing itself, so the caller owns the transport and can advance the run
|
|
21
|
+
# record's sequence with the same list.
|
|
22
|
+
#
|
|
23
|
+
# mapper = RubyLLM::AgUI::Mapper.new(thread_id: "t1", run_id: "r1")
|
|
24
|
+
#
|
|
25
|
+
# chat.before_tool_call { |tool_call| write(mapper.before_tool_call(tool_call)) }
|
|
26
|
+
# chat.after_message { |message| write(mapper.message(message)) }
|
|
27
|
+
# chat.complete { |chunk| write(mapper.chunk(chunk)) }
|
|
28
|
+
#
|
|
29
|
+
# write(mapper.close) # before the run's terminal event
|
|
30
|
+
class Mapper
|
|
31
|
+
# The two phases a step delimits: one model round-trip that produces text
|
|
32
|
+
# and/or tool calls, and the tool round that answers those calls. A step
|
|
33
|
+
# ends as soon as a phase of the other kind begins, so consecutive steps
|
|
34
|
+
# never overlap.
|
|
35
|
+
STEP_COMPLETION = :completion
|
|
36
|
+
STEP_TOOL = :tool
|
|
37
|
+
|
|
38
|
+
# The AG-UI conversation this run belongs to.
|
|
39
|
+
attr_reader :thread_id
|
|
40
|
+
|
|
41
|
+
# Identifies this run. Every id the mapper mints is scoped to it, so two
|
|
42
|
+
# runs on one thread never collide.
|
|
43
|
+
attr_reader :run_id
|
|
44
|
+
|
|
45
|
+
def initialize(thread_id:, run_id:, redact: nil, logger: nil)
|
|
46
|
+
@thread_id = thread_id
|
|
47
|
+
@run_id = run_id
|
|
48
|
+
@redact = redact || RubyLLM::AgUI.config.redact
|
|
49
|
+
@logger = logger || RubyLLM::AgUI.config.logger
|
|
50
|
+
@message_count = 0
|
|
51
|
+
@step_count = 0
|
|
52
|
+
@open_text_id = nil
|
|
53
|
+
@last_message_id = nil
|
|
54
|
+
@text_emitted = false
|
|
55
|
+
@calls = {}
|
|
56
|
+
@open_step = nil
|
|
57
|
+
@step_kind = nil
|
|
58
|
+
@warned_redaction = false
|
|
59
|
+
@closed = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One streamed Chunk from Chat#complete.
|
|
63
|
+
def chunk(chunk)
|
|
64
|
+
return [] if @closed
|
|
65
|
+
|
|
66
|
+
events = text_events(chunk.content)
|
|
67
|
+
events.concat(tool_call_events(chunk.tool_calls, streamed: true))
|
|
68
|
+
events.concat(close_events) if chunk.finish_reason
|
|
69
|
+
events
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One Message appended to the conversation, from Chat#after_message.
|
|
73
|
+
#
|
|
74
|
+
# An assistant message ends the completion, so whatever the stream did not
|
|
75
|
+
# carry is emitted here: a provider that did not stream, or a tool call it
|
|
76
|
+
# reported only in the final message, still produces a valid AG-UI
|
|
77
|
+
# sequence instead of a run that silently says nothing.
|
|
78
|
+
def message(message)
|
|
79
|
+
return [] if @closed
|
|
80
|
+
return tool_result_events(message) if message.role == :tool
|
|
81
|
+
|
|
82
|
+
assistant_events(message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A local ToolCall about to execute, from Chat#before_tool_call.
|
|
86
|
+
#
|
|
87
|
+
# Its arguments are complete, and its deltas have already gone out if the
|
|
88
|
+
# provider streamed them, so this only opens a call the stream never
|
|
89
|
+
# reported and closes the call so the client knows the arguments ended.
|
|
90
|
+
def before_tool_call(tool_call)
|
|
91
|
+
return [] if @closed
|
|
92
|
+
|
|
93
|
+
events = tool_call_events({ tool_call.id => tool_call }, streamed: false)
|
|
94
|
+
events.concat(close_tool_calls)
|
|
95
|
+
events
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Closes any open text message, tool call and step, and closes the mapper.
|
|
99
|
+
#
|
|
100
|
+
# The caller writes these before the terminal event: AG-UI has no way to
|
|
101
|
+
# close a run whose content is still open, and a client that never sees
|
|
102
|
+
# TEXT_MESSAGE_END waits for a message that will not grow.
|
|
103
|
+
#
|
|
104
|
+
# Closing is final, and it is the reason a second turn on the same chat is
|
|
105
|
+
# safe. RubyLLM's callbacks are additive: the callbacks this run registered
|
|
106
|
+
# stay attached after the run ends, so the next `complete` on that chat
|
|
107
|
+
# fires this mapper again. A closed mapper maps nothing — the content it
|
|
108
|
+
# would open belongs to a run that is already over, and writing it would
|
|
109
|
+
# report the previous run's ids and steps inside the next run's stream.
|
|
110
|
+
def close
|
|
111
|
+
@closed = true
|
|
112
|
+
close_events
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# --- text ---------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def text_events(content)
|
|
120
|
+
return [] if content.nil? || content.empty?
|
|
121
|
+
|
|
122
|
+
events = open_text_events
|
|
123
|
+
events << ::AgUiProtocol::Core::Events::TextMessageContentEvent.new(
|
|
124
|
+
message_id: @open_text_id, delta: content
|
|
125
|
+
)
|
|
126
|
+
@text_emitted = true
|
|
127
|
+
events
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def open_text_events
|
|
131
|
+
return [] if @open_text_id
|
|
132
|
+
|
|
133
|
+
@open_text_id = new_message_id('a')
|
|
134
|
+
@last_message_id = @open_text_id
|
|
135
|
+
events = ensure_step(STEP_COMPLETION)
|
|
136
|
+
events << ::AgUiProtocol::Core::Events::TextMessageStartEvent.new(
|
|
137
|
+
message_id: @open_text_id, role: 'assistant'
|
|
138
|
+
)
|
|
139
|
+
events
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def close_text_events
|
|
143
|
+
return [] unless @open_text_id
|
|
144
|
+
|
|
145
|
+
message_id = @open_text_id
|
|
146
|
+
@open_text_id = nil
|
|
147
|
+
[::AgUiProtocol::Core::Events::TextMessageEndEvent.new(message_id: message_id)]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- assistant message ---------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def assistant_events(message)
|
|
153
|
+
events = []
|
|
154
|
+
events.concat(text_events(message.content)) unless @text_emitted
|
|
155
|
+
events.concat(close_text_events)
|
|
156
|
+
events.concat(tool_call_events(message.tool_calls, streamed: false))
|
|
157
|
+
events.concat(close_tool_calls)
|
|
158
|
+
events.concat(close_step)
|
|
159
|
+
@last_message_id = nil
|
|
160
|
+
@text_emitted = false
|
|
161
|
+
events
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- tool calls ----------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def tool_call_events(tool_calls, streamed:)
|
|
167
|
+
return [] if tool_calls.nil? || tool_calls.empty?
|
|
168
|
+
|
|
169
|
+
tool_calls.flat_map do |key, tool_call|
|
|
170
|
+
record = track(key, tool_call, streamed: streamed)
|
|
171
|
+
open_tool_call(record) + argument_events(record, tool_call, streamed: streamed)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The record for one tool call, created on first sight.
|
|
176
|
+
#
|
|
177
|
+
# A streamed delta is keyed by index and carries the id only on its first
|
|
178
|
+
# fragment; the complete message is keyed by id. Both keys point at the
|
|
179
|
+
# same record, so the call is opened once.
|
|
180
|
+
def track(key, tool_call, streamed:)
|
|
181
|
+
record = @calls[key] || find_call(tool_call.id) || new_call
|
|
182
|
+
record[:id] ||= tool_call.id
|
|
183
|
+
record[:name] ||= tool_call.name
|
|
184
|
+
record[:streamed] ||= streamed
|
|
185
|
+
@calls[key] = record
|
|
186
|
+
@calls[record[:id]] = record if record[:id]
|
|
187
|
+
record
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def new_call
|
|
191
|
+
{ id: nil, name: nil, streamed: false, args_emitted: false, started: false, closed: false, buffered: nil }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def find_call(id)
|
|
195
|
+
id && @calls[id]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Opens the call the first time its id and name are both known. A call
|
|
199
|
+
# reported as arguments-only deltas stays buffered until its name arrives,
|
|
200
|
+
# so no fragment of the provider's argument bytes is dropped.
|
|
201
|
+
def open_tool_call(record)
|
|
202
|
+
return [] if record[:started] || record[:id].nil? || record[:name].nil?
|
|
203
|
+
|
|
204
|
+
record[:started] = true
|
|
205
|
+
events = close_text_events
|
|
206
|
+
events.concat(ensure_step(STEP_COMPLETION))
|
|
207
|
+
events << ::AgUiProtocol::Core::Events::ToolCallStartEvent.new(
|
|
208
|
+
tool_call_id: record[:id], tool_call_name: record[:name], parent_message_id: @last_message_id
|
|
209
|
+
)
|
|
210
|
+
events.concat(buffered_argument_events(record))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def buffered_argument_events(record)
|
|
214
|
+
buffered = record[:buffered]
|
|
215
|
+
record[:buffered] = nil
|
|
216
|
+
return [] if buffered.nil? || buffered.empty?
|
|
217
|
+
|
|
218
|
+
[::AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: record[:id], delta: buffered)]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def argument_events(record, tool_call, streamed:)
|
|
222
|
+
delta = argument_delta(record, tool_call, streamed: streamed)
|
|
223
|
+
return [] if delta.nil? || delta.empty?
|
|
224
|
+
return buffer_argument(record, delta) unless record[:started]
|
|
225
|
+
|
|
226
|
+
[::AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: record[:id], delta: delta)]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def buffer_argument(record, delta)
|
|
230
|
+
record[:buffered] = (record[:buffered] || +'') + delta
|
|
231
|
+
[]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A streamed chunk carries a fragment of the provider's own argument bytes
|
|
235
|
+
# and goes out unchanged. A complete message carries the Hash RubyLLM
|
|
236
|
+
# reconstructed from those bytes, so it is serialized once and only when
|
|
237
|
+
# the stream did not already deliver the fragments.
|
|
238
|
+
def argument_delta(record, tool_call, streamed:)
|
|
239
|
+
return streamed_delta(tool_call) if streamed
|
|
240
|
+
return nil if record[:streamed] || record[:args_emitted]
|
|
241
|
+
|
|
242
|
+
record[:args_emitted] = true
|
|
243
|
+
arguments = tool_call.arguments
|
|
244
|
+
arguments.is_a?(String) ? arguments : Canonical.dump(arguments)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def streamed_delta(tool_call)
|
|
248
|
+
arguments = tool_call.arguments
|
|
249
|
+
arguments.is_a?(String) ? arguments : arguments.to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def close_tool_calls
|
|
253
|
+
@calls.values.uniq.filter_map do |record|
|
|
254
|
+
next if record[:closed] || !record[:started]
|
|
255
|
+
|
|
256
|
+
record[:closed] = true
|
|
257
|
+
::AgUiProtocol::Core::Events::ToolCallEndEvent.new(tool_call_id: record[:id])
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# --- tool results --------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def tool_result_events(message)
|
|
264
|
+
events = close_text_events
|
|
265
|
+
events.concat(close_tool_calls)
|
|
266
|
+
events.concat(ensure_step(STEP_TOOL))
|
|
267
|
+
events << ::AgUiProtocol::Core::Events::ToolCallResultEvent.new(
|
|
268
|
+
message_id: "#{@run_id}:tool:#{message.tool_call_id}",
|
|
269
|
+
tool_call_id: message.tool_call_id,
|
|
270
|
+
content: redact(Redaction::TOOL_RESULT, message.content.to_s),
|
|
271
|
+
role: 'tool'
|
|
272
|
+
)
|
|
273
|
+
@text_emitted = false
|
|
274
|
+
events
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# --- steps ---------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
# Ensures the step for +kind+ is the open one, closing the previous step
|
|
280
|
+
# first when the phase changed. Opening a step of the same kind is a no-op,
|
|
281
|
+
# so the deltas of one completion stay inside a single step.
|
|
282
|
+
def ensure_step(kind)
|
|
283
|
+
return [] if @open_step && @step_kind == kind
|
|
284
|
+
|
|
285
|
+
events = close_step
|
|
286
|
+
@step_count += 1
|
|
287
|
+
@open_step = "step-#{@step_count}"
|
|
288
|
+
@step_kind = kind
|
|
289
|
+
events << ::AgUiProtocol::Core::Events::StepStartedEvent.new(step_name: @open_step)
|
|
290
|
+
events
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def close_step
|
|
294
|
+
return [] unless @open_step
|
|
295
|
+
|
|
296
|
+
step_name = @open_step
|
|
297
|
+
@open_step = nil
|
|
298
|
+
@step_kind = nil
|
|
299
|
+
[::AgUiProtocol::Core::Events::StepFinishedEvent.new(step_name: step_name)]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# --- closing -------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def close_events
|
|
305
|
+
events = close_text_events
|
|
306
|
+
events.concat(close_tool_calls)
|
|
307
|
+
events.concat(close_step)
|
|
308
|
+
events
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# --- redaction -----------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def redact(kind, payload)
|
|
314
|
+
warn_about_redaction
|
|
315
|
+
Redaction.apply(@redact, kind, payload)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def warn_about_redaction
|
|
319
|
+
return unless @redact == Redaction.method(:default)
|
|
320
|
+
return if @warned_redaction
|
|
321
|
+
|
|
322
|
+
@warned_redaction = true
|
|
323
|
+
@logger&.warn('[ruby_llm_ag_ui] tool results are redacted by default; ' \
|
|
324
|
+
'configure RubyLLM::AgUI.config.redact to publish them')
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def new_message_id(suffix)
|
|
328
|
+
@message_count += 1
|
|
329
|
+
"#{@run_id}:#{suffix}#{@message_count}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# Parses the RunAgentInput messages array into typed protocol message
|
|
6
|
+
# models.
|
|
7
|
+
#
|
|
8
|
+
# Hosts must reconcile client history by id against their own store; the
|
|
9
|
+
# gem never trusts a client message as a system instruction or a tool
|
|
10
|
+
# result. This parser only guarantees the shapes the pinned schema defines,
|
|
11
|
+
# so a mismatch is a clear InputError instead of a downstream surprise.
|
|
12
|
+
class MessageParser
|
|
13
|
+
include Validation
|
|
14
|
+
|
|
15
|
+
# The roles the pinned schema defines.
|
|
16
|
+
ROLES = %w[developer system assistant user tool activity reasoning].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(logger: nil)
|
|
19
|
+
@logger = logger || RubyLLM::AgUI.config.logger
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns an array of AgUiProtocol::Core::Types message models.
|
|
23
|
+
def parse(messages)
|
|
24
|
+
messages.each_with_index.map { |message, index| build(message, index) }.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build(value, index)
|
|
30
|
+
hash = object!(value, "messages[#{index}]")
|
|
31
|
+
id = identifier!(hash['id'], "messages[#{index}].id")
|
|
32
|
+
role = string!(hash['role'], "messages[#{index}].role", max: 32)
|
|
33
|
+
unless ROLES.include?(role)
|
|
34
|
+
raise InvalidInputError, "messages[#{index}].role #{role.inspect} is not a known role."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
send(:"build_#{role}", hash, index, id)
|
|
38
|
+
rescue ArgumentError, KeyError, TypeError
|
|
39
|
+
raise InvalidInputError, "messages[#{index}] is not a valid #{role} message."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_developer(hash, index, id)
|
|
43
|
+
types = ::AgUiProtocol::Core::Types
|
|
44
|
+
types::DeveloperMessage.new(
|
|
45
|
+
id: id,
|
|
46
|
+
content: string!(hash['content'], "messages[#{index}].content"),
|
|
47
|
+
name: optional_string(hash['name'], "messages[#{index}].name")
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_system(hash, index, id)
|
|
52
|
+
types = ::AgUiProtocol::Core::Types
|
|
53
|
+
types::SystemMessage.new(
|
|
54
|
+
id: id,
|
|
55
|
+
content: string!(hash['content'], "messages[#{index}].content"),
|
|
56
|
+
name: optional_string(hash['name'], "messages[#{index}].name")
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_user(hash, index, id)
|
|
61
|
+
content = hash['content']
|
|
62
|
+
unless content.is_a?(String) || content.is_a?(Array)
|
|
63
|
+
raise InvalidInputError, "messages[#{index}].content must be text or an array of parts."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
string!(content, "messages[#{index}].content") if content.is_a?(String)
|
|
67
|
+
::AgUiProtocol::Core::Types::UserMessage.new(
|
|
68
|
+
id: id, content: content, name: optional_string(hash['name'], "messages[#{index}].name")
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_assistant(hash, index, id)
|
|
73
|
+
content = hash['content']
|
|
74
|
+
string!(content, "messages[#{index}].content") unless content.nil?
|
|
75
|
+
::AgUiProtocol::Core::Types::AssistantMessage.new(
|
|
76
|
+
id: id,
|
|
77
|
+
content: content,
|
|
78
|
+
tool_calls: tool_calls(hash['toolCalls'], index),
|
|
79
|
+
name: optional_string(hash['name'], "messages[#{index}].name")
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_tool(hash, index, id)
|
|
84
|
+
::AgUiProtocol::Core::Types::ToolMessage.new(
|
|
85
|
+
id: id,
|
|
86
|
+
content: tool_content(hash['content'], index),
|
|
87
|
+
tool_call_id: identifier!(hash['toolCallId'], "messages[#{index}].toolCallId"),
|
|
88
|
+
error: optional_string(hash['error'], "messages[#{index}].error")
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_activity(hash, index, id)
|
|
93
|
+
activity_type = string!(hash['activityType'], "messages[#{index}].activityType", max: MAX_ID_LENGTH)
|
|
94
|
+
content = object!(hash['content'], "messages[#{index}].content")
|
|
95
|
+
::AgUiProtocol::Core::Types::ActivityMessage.new(
|
|
96
|
+
id: id, activity_type: activity_type, content: content
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_reasoning(hash, index, id)
|
|
101
|
+
::AgUiProtocol::Core::Types::ReasoningMessage.new(
|
|
102
|
+
id: id, content: string!(hash['content'], "messages[#{index}].content")
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def tool_calls(value, index)
|
|
107
|
+
return nil if value.nil?
|
|
108
|
+
|
|
109
|
+
array!(value, "messages[#{index}].toolCalls").map do |call|
|
|
110
|
+
object!(call, "messages[#{index}].toolCalls[]")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The schema allows a tool result to be text or an ordered list of parts.
|
|
115
|
+
# ToolMessage models text only, so a structured result is serialized to
|
|
116
|
+
# JSON text, which is how the schema says structured data travels.
|
|
117
|
+
def tool_content(value, index)
|
|
118
|
+
raise InvalidInputError, "messages[#{index}].content is required." if value.nil?
|
|
119
|
+
|
|
120
|
+
return value if value.is_a?(String)
|
|
121
|
+
|
|
122
|
+
array!(value, "messages[#{index}].content")
|
|
123
|
+
Canonical.dump(value)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|