ruby_llm-ag_ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +48 -0
  3. data/CONTRIBUTING.md +56 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +146 -0
  6. data/SECURITY.md +30 -0
  7. data/docs/_advanced/rails-integration.md +199 -0
  8. data/docs/_advanced/security-and-deployment.md +204 -0
  9. data/docs/_core_features/approval-and-resume.md +62 -0
  10. data/docs/_core_features/input-validation.md +64 -0
  11. data/docs/_core_features/reconnects.md +42 -0
  12. data/docs/_core_features/streaming-runs.md +49 -0
  13. data/docs/_getting_started/configuration.md +57 -0
  14. data/docs/_getting_started/getting-started.md +52 -0
  15. data/docs/_getting_started/overview.md +189 -0
  16. data/docs/_getting_started/whats-new-in-0-1.md +41 -0
  17. data/docs/_reference/api-reference.md +26 -0
  18. data/docs/_reference/compatibility.md +130 -0
  19. data/docs/_reference/protocol-coverage.md +175 -0
  20. data/docs/_reference/release-history.md +22 -0
  21. data/docs/_reference/releasing.md +54 -0
  22. data/docs/_reference/third-party-notices.md +82 -0
  23. data/docs/_reference/upgrading.md +42 -0
  24. data/docs/index.md +53 -0
  25. data/lib/generators/ruby_llm/ag_ui/install_generator.rb +37 -0
  26. data/lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt +24 -0
  27. data/lib/generators/ruby_llm/ag_ui/templates/initializer.rb.tt +24 -0
  28. data/lib/ruby_llm/ag_ui/canonical.rb +40 -0
  29. data/lib/ruby_llm/ag_ui/configuration.rb +113 -0
  30. data/lib/ruby_llm/ag_ui/coverage.rb +163 -0
  31. data/lib/ruby_llm/ag_ui/emitter.rb +119 -0
  32. data/lib/ruby_llm/ag_ui/error.rb +69 -0
  33. data/lib/ruby_llm/ag_ui/error_sanitizer.rb +69 -0
  34. data/lib/ruby_llm/ag_ui/input.rb +299 -0
  35. data/lib/ruby_llm/ag_ui/interrupt.rb +207 -0
  36. data/lib/ruby_llm/ag_ui/mapper.rb +333 -0
  37. data/lib/ruby_llm/ag_ui/message_parser.rb +127 -0
  38. data/lib/ruby_llm/ag_ui/protocol.rb +130 -0
  39. data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +119 -0
  40. data/lib/ruby_llm/ag_ui/rails/railtie.rb +29 -0
  41. data/lib/ruby_llm/ag_ui/rails/record.rb +51 -0
  42. data/lib/ruby_llm/ag_ui/rails/streaming.rb +166 -0
  43. data/lib/ruby_llm/ag_ui/reconnect.rb +86 -0
  44. data/lib/ruby_llm/ag_ui/redaction.rb +58 -0
  45. data/lib/ruby_llm/ag_ui/redemption.rb +62 -0
  46. data/lib/ruby_llm/ag_ui/reservation.rb +56 -0
  47. data/lib/ruby_llm/ag_ui/resume.rb +108 -0
  48. data/lib/ruby_llm/ag_ui/resume_entry.rb +49 -0
  49. data/lib/ruby_llm/ag_ui/run.rb +177 -0
  50. data/lib/ruby_llm/ag_ui/run_record.rb +229 -0
  51. data/lib/ruby_llm/ag_ui/sse_writer.rb +119 -0
  52. data/lib/ruby_llm/ag_ui/store/base.rb +123 -0
  53. data/lib/ruby_llm/ag_ui/store/memory_store.rb +123 -0
  54. data/lib/ruby_llm/ag_ui/validation.rb +63 -0
  55. data/lib/ruby_llm/ag_ui/version.rb +8 -0
  56. data/lib/ruby_llm/ag_ui.rb +65 -0
  57. metadata +223 -0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Writes one run's events and keeps its durable record in step.
6
+ #
7
+ # The mapper decides which events a RubyLLM signal becomes; the emitter is
8
+ # what makes them durable and ordered. Every event advances the run record's
9
+ # sequence, so a reconnect can order snapshots by it instead of by wall
10
+ # clock, and a terminal outcome is persisted *before* it is written: if the
11
+ # transport dies after the outcome is decided, the run still cannot be
12
+ # executed a second time.
13
+ #
14
+ # The emitter decides nothing about the outcome and executes nothing. What it
15
+ # owns is the one ordering rule a caller should never have to remember: a
16
+ # run's open text, tool calls and step are closed before the terminal event.
17
+ class Emitter
18
+ # The run record, including the sequence numbers written so far.
19
+ attr_reader :record
20
+
21
+ # rubocop:disable-next Metrics/ParameterLists -- one keyword per collaborator the caller injects
22
+ def initialize(writer:, mapper:, store:, record:, clock:, logger: nil)
23
+ @writer = writer
24
+ @mapper = mapper
25
+ @store = store
26
+ @record = record
27
+ @clock = clock
28
+ @logger = logger
29
+ end
30
+
31
+ # Moves the run to started, records +state+ as the host-owned state a
32
+ # reconnect serves, then opens the run on the wire with +event+.
33
+ def start(event, state: nil)
34
+ @record = persist(@record.start(now: now, snapshot: state))
35
+ write(event)
36
+ @record
37
+ end
38
+
39
+ # Writes +events+ (one event or an array) and advances the run's sequence
40
+ # by one per event.
41
+ def write(events)
42
+ Array(events).each do |event|
43
+ emit(event)
44
+ @record = @record.advance(now: now)
45
+ end
46
+ end
47
+
48
+ # Writes a reconnect's events without advancing or persisting the record.
49
+ # The run already ended; a reconnect reports it rather than changing it.
50
+ def replay(events)
51
+ Array(events).each { |event| emit(event) }
52
+ @record
53
+ end
54
+
55
+ # Closes the run as succeeded.
56
+ def succeed
57
+ @record = persist(@record.finish(RunRecord::SUCCEEDED, now: now))
58
+ close(run_finished(Protocol.success_outcome))
59
+ @record
60
+ end
61
+
62
+ # Closes a run that was stopped before it completed. Never reported as a
63
+ # success: the outcome carries the protocol's own "cancelled"
64
+ # discriminator, which 0.2.0 cannot express.
65
+ def cancel
66
+ @record = persist(@record.finish(RunRecord::CANCELLED, now: now))
67
+ close(run_finished(Protocol.cancelled_outcome))
68
+ @record
69
+ end
70
+
71
+ # Closes the run as paused, recording +interrupts+ so a later resume can
72
+ # answer them by id. At least one must be unresolved, which RunRecord
73
+ # enforces.
74
+ def interrupt(interrupts)
75
+ @record = persist(@record.interrupted(interrupts, now: now))
76
+ close(run_finished(Protocol.interrupt_outcome(interrupts)))
77
+ @record
78
+ end
79
+
80
+ # Closes the run as failed with a sanitized RUN_ERROR.
81
+ #
82
+ # The record is persisted first, so a run that failed is not retried
83
+ # because reporting the failure failed too. The exception's own message is
84
+ # never sent: it may carry prompts, keys or paths.
85
+ def fail(error)
86
+ message, code = ErrorSanitizer.sanitize(error)
87
+ failure = { 'message' => message, 'code' => code }
88
+ @logger&.warn("[ruby_llm_ag_ui] run #{@record.run_id} failed: #{error.class}")
89
+ @record = persist(@record.finish(RunRecord::FAILED, now: now, failure: failure))
90
+ close(::AgUiProtocol::Core::Events::RunErrorEvent.new(message: message, code: code))
91
+ @record
92
+ end
93
+
94
+ private
95
+
96
+ def emit(event)
97
+ @writer.write(event)
98
+ end
99
+
100
+ # Closes whatever content is still open, then writes the terminal event.
101
+ def close(terminal_event)
102
+ write(@mapper.close)
103
+ write(terminal_event)
104
+ end
105
+
106
+ def run_finished(outcome)
107
+ Protocol.run_finished(thread_id: @record.thread_id, run_id: @record.run_id, outcome: outcome)
108
+ end
109
+
110
+ def persist(record)
111
+ @store.save(record)
112
+ end
113
+
114
+ def now
115
+ @clock.call
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Base class for every error this gem raises. Rescue it to catch any
6
+ # AG-UI mapping failure without catching unrelated RubyLLM or host errors.
7
+ class Error < StandardError; end
8
+
9
+ # Raised when an inbound request cannot be accepted. The message is always
10
+ # safe to send to a client: it never contains raw request bytes.
11
+ class InputError < Error
12
+ # The HTTP status the host should use for this rejection, when it can.
13
+ def http_status
14
+ 400
15
+ end
16
+ end
17
+
18
+ # Raised when inbound JSON is not valid JSON, or not an object.
19
+ class MalformedInputError < InputError; end
20
+
21
+ # Raised when inbound JSON exceeds the configured size or member bounds.
22
+ class InputTooLargeError < InputError
23
+ def http_status
24
+ 413
25
+ end
26
+ end
27
+
28
+ # Raised when inbound JSON is well formed but violates the AG-UI 1.0
29
+ # RunAgentInput schema.
30
+ class InvalidInputError < InputError; end
31
+
32
+ # Raised when the same (scope, threadId, runId) is reserved twice with
33
+ # different input. A duplicate with an identical input attaches instead.
34
+ class RunConflictError < InputError
35
+ def http_status
36
+ 409
37
+ end
38
+ end
39
+
40
+ # Raised when run identity is required but missing.
41
+ class InvalidRunIdentityError < InvalidInputError; end
42
+
43
+ # Raised when a resume entry does not match a live interrupt of the most
44
+ # recent interrupted run on the thread, or was already redeemed. Fails
45
+ # closed: nothing is executed and no decision is recorded.
46
+ class ResumeRejectedError < InputError
47
+ def http_status
48
+ 409
49
+ end
50
+ end
51
+
52
+ # Raised when the host did not supply something a run needs (a chat, a run
53
+ # store, an actor scope) or supplied something malformed.
54
+ class ConfigurationError < Error; end
55
+
56
+ # Raised when the pinned protocol version is not the one this gem speaks.
57
+ class UnsupportedProtocolVersionError < InputError; end
58
+
59
+ # Raised when the stream cannot be closed in order (for example the
60
+ # transport died mid-run). The run is reported as RUN_ERROR.
61
+ class StreamError < Error; end
62
+
63
+ # Raised when RubyLLM paused the run for something the pinned AG-UI shape
64
+ # cannot express as an interrupt this producer may answer, such as an MCP
65
+ # input request or a long-running MCP task. Reported as a sanitized
66
+ # RUN_ERROR rather than a success or a bare interrupt.
67
+ class UnsupportedRunStateError < Error; end
68
+ end
69
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Turns an exception into a safe (message, code) pair for RUN_ERROR.
6
+ #
7
+ # Upstream Rails examples put `e.message` straight into RUN_ERROR, which
8
+ # can leak prompts, request bodies, API keys and internal paths. This
9
+ # module never does that: it classifies the error by class and returns a
10
+ # fixed, public message. The raw exception goes to the host logger, if the
11
+ # host configured one, and nowhere else.
12
+ module ErrorSanitizer
13
+ # Ordered by specificity. The first ancestor of the error whose name is a
14
+ # key wins, so subclass relationships are respected without loading
15
+ # optional error classes eagerly.
16
+ MESSAGES = {
17
+ 'RubyLLM::UnauthorizedError' => ['The model provider rejected the request.', 'provider_unauthorized'],
18
+ 'RubyLLM::ForbiddenError' => ['The model provider refused the request.', 'provider_forbidden'],
19
+ 'RubyLLM::PaymentRequiredError' => ['The model provider account needs attention.', 'provider_payment_required'],
20
+ 'RubyLLM::RateLimitError' => ['The model provider rate limit was reached.', 'provider_rate_limited'],
21
+ 'RubyLLM::ContextLengthExceededError' => [
22
+ 'The conversation exceeded the model context window.', 'context_length_exceeded'
23
+ ],
24
+ 'RubyLLM::ContentFilterError' => ['The model provider filtered the response.', 'content_filtered'],
25
+ 'RubyLLM::OverloadedError' => ['The model provider is temporarily unavailable.', 'provider_unavailable'],
26
+ 'RubyLLM::ServiceUnavailableError' => [
27
+ 'The model provider is temporarily unavailable.', 'provider_unavailable'
28
+ ],
29
+ 'RubyLLM::ServerError' => ['The model provider is temporarily unavailable.', 'provider_unavailable'],
30
+ 'RubyLLM::BadRequestError' => ['The model provider rejected the request shape.', 'provider_bad_request'],
31
+ 'RubyLLM::InvalidToolChoiceError' => ['The requested tool choice is not available.', 'invalid_tool_choice'],
32
+ 'RubyLLM::ToolCallParseError' => ['The model produced an unparseable tool call.', 'tool_call_parse_error'],
33
+ 'RubyLLM::UnsupportedAttachmentError' => ['An attachment could not be sent.', 'unsupported_attachment'],
34
+ 'RubyLLM::ModelNotFoundError' => ['The model is not available.', 'model_not_found'],
35
+ 'RubyLLM::AgUI::UnsupportedRunStateError' => [
36
+ 'The run paused for input this producer cannot express.', 'unsupported_run_state'
37
+ ],
38
+ 'RubyLLM::AgUI::ResumeRejectedError' => [
39
+ 'The resume request could not be accepted.', 'resume_rejected'
40
+ ],
41
+ 'RubyLLM::AgUI::StreamError' => ['The run could not be streamed to completion.', 'stream_failed'],
42
+ 'RubyLLM::Error' => ['The model provider returned an error.', 'provider_error'],
43
+ 'Faraday::Error' => ['The model provider request failed.', 'transport_error'],
44
+ 'Timeout::Error' => ['The model provider request timed out.', 'transport_timeout'],
45
+ 'Errno::ETIMEDOUT' => ['The model provider request timed out.', 'transport_timeout']
46
+ }.freeze
47
+
48
+ FALLBACK = ['The run failed before it could finish.', 'internal_error'].freeze
49
+
50
+ module_function
51
+
52
+ # Returns a [message, code] pair safe to put on the wire.
53
+ def sanitize(error)
54
+ match = classify(error)
55
+ match || FALLBACK
56
+ end
57
+
58
+ # Returns the [message, code] pair for +error+, falling back to FALLBACK.
59
+ def classify(error)
60
+ names = error.class.ancestors.map(&:name).compact
61
+ names.each do |name|
62
+ entry = MESSAGES[name]
63
+ return entry if entry
64
+ end
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # A validated AG-UI 1.0 RunAgentInput.
6
+ #
7
+ # The community Ruby SDK models RunAgentInput but has no JSON to model
8
+ # parser, so hosts parse and authorize the body by hand. This class does
9
+ # that once: it bounds the body size, decodes it, strips members the pinned
10
+ # schema does not define, validates the shapes the schema requires, and
11
+ # hands the run typed values and a stable digest for run identity.
12
+ #
13
+ # input = RubyLLM::AgUI::Input.parse(request_body, max_bytes: 256_000)
14
+ # input.thread_id # => "thread-1"
15
+ # input.resume # => [#<RubyLLM::AgUI::ResumeEntry ...>]
16
+ # input.digest # => "9f2c..."
17
+ #
18
+ # Nothing here grants trust. Client messages, state, tools, context and
19
+ # forwardedProps are accepted as data only; the host decides which tools
20
+ # and model a run may use, and client history is reconciled by id against
21
+ # host-stored history rather than appended as trusted system or tool turns.
22
+ class Input
23
+ # The protocol versions this gem can act on.
24
+ SUPPORTED_PROTOCOL_VERSIONS = [Protocol::PROTOCOL_VERSION].freeze
25
+
26
+ # Members the pinned RunAgentInput schema defines. Anything else is not
27
+ # part of the contract and is stripped (or rejected with strict: true).
28
+ TOP_LEVEL_KEYS = %w[
29
+ threadId runId protocolVersion parentRunId state messages tools context forwardedProps resume
30
+ ].freeze
31
+
32
+ # The conversation this run belongs to.
33
+ attr_reader :thread_id
34
+
35
+ # Identifies this run.
36
+ attr_reader :run_id
37
+
38
+ # The run that spawned this one, when the client sets one.
39
+ attr_reader :parent_run_id
40
+
41
+ # The AG-UI protocol version the client declared, or nil when absent.
42
+ attr_reader :protocol_version
43
+
44
+ # The state the run starts from.
45
+ attr_reader :state
46
+
47
+ # The parsed conversation, as AgUiProtocol::Core::Types message models.
48
+ attr_reader :messages
49
+
50
+ # The client-supplied tool definitions, as frozen hashes. Never used to
51
+ # authorize a host tool.
52
+ attr_reader :tools
53
+
54
+ # The client-supplied context entries, as frozen hashes.
55
+ attr_reader :context
56
+
57
+ # Application-specific values passed through untouched.
58
+ attr_reader :forwarded_props
59
+
60
+ # The resume entries answering a previous run's interrupts.
61
+ attr_reader :resume
62
+
63
+ # The canonical digest of the accepted input, for run identity.
64
+ attr_reader :digest
65
+
66
+ # Non-fatal observations made while parsing (unknown members stripped).
67
+ attr_reader :warnings
68
+
69
+ def self.parse(raw, max_bytes: nil, strict: false, logger: nil)
70
+ Parser.new(raw, max_bytes: max_bytes, strict: strict, logger: logger).parse
71
+ end
72
+
73
+ def initialize(attributes)
74
+ @thread_id = attributes[:thread_id]
75
+ @run_id = attributes[:run_id]
76
+ @parent_run_id = attributes[:parent_run_id]
77
+ @protocol_version = attributes[:protocol_version]
78
+ @state = attributes[:state]
79
+ @messages = attributes[:messages]
80
+ @tools = attributes[:tools]
81
+ @context = attributes[:context]
82
+ @forwarded_props = attributes[:forwarded_props]
83
+ @resume = attributes[:resume]
84
+ @digest = attributes[:digest]
85
+ @normalized = attributes[:normalized]
86
+ @warnings = attributes[:warnings]
87
+ freeze
88
+ end
89
+
90
+ # The accepted input as a plain hash with camelCase keys and the members
91
+ # the schema defines. Suitable for echoing as RUN_STARTED.input.
92
+ def to_h
93
+ @normalized
94
+ end
95
+
96
+ # The names of the client-supplied tools. Informational only.
97
+ def tool_names
98
+ @tools.filter_map { |tool| tool['name'] }
99
+ end
100
+
101
+ # Whether the client supplied tool definitions. The first release never
102
+ # grants them; a run reports them and stops.
103
+ def client_tools?
104
+ !@tools.empty?
105
+ end
106
+
107
+ # Whether this run answers a previous run's interrupts.
108
+ def resume?
109
+ !@resume.empty?
110
+ end
111
+
112
+ # The resume entry for +interrupt_id+, or nil.
113
+ def resume_entry(interrupt_id)
114
+ @resume.find { |entry| entry.interrupt_id == interrupt_id }
115
+ end
116
+
117
+ # Decodes and validates one request body.
118
+ #
119
+ # Kept separate from Input so the value object stays immutable and every
120
+ # failure message stays in one place.
121
+ class Parser
122
+ include Validation
123
+
124
+ MAX_NESTING = 32
125
+
126
+ def initialize(raw, max_bytes:, strict:, logger:)
127
+ @raw = raw
128
+ @max_bytes = max_bytes || RubyLLM::AgUI.config.max_input_bytes
129
+ @strict = strict
130
+ @logger = logger || RubyLLM::AgUI.config.logger
131
+ @warnings = []
132
+ @parts = PartBuilder.new
133
+ end
134
+
135
+ def parse
136
+ document = strip_unknown_members(decode(read_body))
137
+ validate_common(document)
138
+ build(document)
139
+ end
140
+
141
+ private
142
+
143
+ def read_body
144
+ case @raw
145
+ when String
146
+ bounded!(@raw.bytesize)
147
+ @raw
148
+ when Hash
149
+ bounded!(Canonical.dump(@raw).bytesize)
150
+ @raw
151
+ when nil then raise MalformedInputError, 'The request body is empty.'
152
+ else read_stream
153
+ end
154
+ end
155
+
156
+ def read_stream
157
+ unless @raw.respond_to?(:read)
158
+ raise MalformedInputError, 'The request body must be a JSON object or a readable stream.'
159
+ end
160
+
161
+ body = @raw.read(@max_bytes + 1) || ''
162
+ bounded!(body.bytesize)
163
+ body
164
+ end
165
+
166
+ def bounded!(bytes)
167
+ return if bytes <= @max_bytes
168
+
169
+ raise InputTooLargeError, "The request body exceeds the #{@max_bytes} byte limit."
170
+ end
171
+
172
+ def decode(body)
173
+ source = body.is_a?(String) ? body : Canonical.dump(body)
174
+ parsed = JSON.parse(source, max_nesting: MAX_NESTING)
175
+ raise MalformedInputError, 'The request body must be a JSON object.' unless parsed.is_a?(Hash)
176
+
177
+ parsed
178
+ rescue JSON::ParserError
179
+ raise MalformedInputError, 'The request body is not valid JSON.'
180
+ end
181
+
182
+ def strip_unknown_members(document)
183
+ unknown = document.keys - TOP_LEVEL_KEYS
184
+ reject_unknown!(unknown) if @strict && !unknown.empty?
185
+ unknown.sort.each { |key| warn_unknown(key) }
186
+ document.slice(*TOP_LEVEL_KEYS)
187
+ end
188
+
189
+ def reject_unknown!(unknown)
190
+ raise InvalidInputError, "Unknown RunAgentInput members: #{unknown.sort.join(', ')}."
191
+ end
192
+
193
+ def warn_unknown(key)
194
+ @warnings << "stripped unknown RunAgentInput member #{key.inspect}"
195
+ @logger&.warn("[ruby_llm_ag_ui] stripped unknown RunAgentInput member #{key.inspect}")
196
+ end
197
+
198
+ def validate_common(document)
199
+ document['threadId'] = identifier!(document['threadId'], 'threadId')
200
+ document['runId'] = identifier!(document['runId'], 'runId')
201
+ document['parentRunId'] = identifier!(document['parentRunId'], 'parentRunId') if document.key?('parentRunId')
202
+ validate_protocol_version(document['protocolVersion'])
203
+ document['messages'] = array!(document['messages'], 'messages', required: true)
204
+ validate_messages_count(document['messages'])
205
+ document['tools'] = array!(document['tools'], 'tools', max: RubyLLM::AgUI.config.max_tools)
206
+ document['context'] = array!(document['context'], 'context', max: RubyLLM::AgUI.config.max_context)
207
+ document['resume'] = array!(document['resume'], 'resume')
208
+ document['state'] ||= {}
209
+ document['forwardedProps'] ||= {}
210
+ end
211
+
212
+ def validate_protocol_version(value)
213
+ return if value.nil?
214
+ return if value.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(value)
215
+
216
+ raise UnsupportedProtocolVersionError,
217
+ "Unsupported protocolVersion #{value.inspect}. " \
218
+ "This producer speaks #{SUPPORTED_PROTOCOL_VERSIONS.join(', ')}."
219
+ end
220
+
221
+ def validate_messages_count(messages)
222
+ max = RubyLLM::AgUI.config.max_messages
223
+ return if messages.size <= max
224
+
225
+ raise InputTooLargeError, "messages exceeds the #{max} message limit."
226
+ end
227
+
228
+ def build(document)
229
+ Input.new(
230
+ thread_id: document['threadId'],
231
+ run_id: document['runId'],
232
+ parent_run_id: document['parentRunId'],
233
+ protocol_version: document['protocolVersion'],
234
+ state: document['state'],
235
+ messages: MessageParser.new(logger: @logger).parse(document['messages']),
236
+ tools: @parts.tools(document['tools']),
237
+ context: @parts.context(document['context']),
238
+ forwarded_props: document['forwardedProps'],
239
+ resume: @parts.resume(document['resume']),
240
+ digest: Canonical.digest(document),
241
+ normalized: Canonical.sort(document),
242
+ warnings: @warnings.dup.freeze
243
+ )
244
+ end
245
+ end
246
+
247
+ # Builds the composite members of a RunAgentInput: the client tool
248
+ # definitions, the context entries and the resume entries.
249
+ class PartBuilder
250
+ include Validation
251
+
252
+ def tools(values)
253
+ values.map { |value| build_tool(value) }.freeze
254
+ end
255
+
256
+ def context(values)
257
+ values.map { |value| build_context(value) }.freeze
258
+ end
259
+
260
+ def resume(values)
261
+ values.map { |value| build_resume(value) }.freeze
262
+ end
263
+
264
+ private
265
+
266
+ def build_tool(value)
267
+ hash = object!(value, 'tools[]')
268
+ {
269
+ 'name' => identifier!(hash['name'], 'tools[].name'),
270
+ 'description' => optional_string(hash['description'], 'tools[].description'),
271
+ 'parameters' => hash['parameters']
272
+ }.freeze
273
+ end
274
+
275
+ def build_context(value)
276
+ hash = object!(value, 'context[]')
277
+ {
278
+ 'description' => string!(hash['description'], 'context[].description'),
279
+ 'value' => string!(hash['value'], 'context[].value')
280
+ }.freeze
281
+ end
282
+
283
+ def build_resume(value)
284
+ hash = object!(value, 'resume[]')
285
+ status = string!(hash['status'], 'resume[].status', max: 32)
286
+ unless ResumeEntry::STATUSES.include?(status)
287
+ raise InvalidInputError, "resume[].status must be one of #{ResumeEntry::STATUSES.join(', ')}."
288
+ end
289
+
290
+ ResumeEntry.new(
291
+ interrupt_id: identifier!(hash['interruptId'], 'resume[].interruptId'),
292
+ status: status,
293
+ payload: hash['payload']
294
+ )
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end