ruby_llm-ag_ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +48 -0
- data/CONTRIBUTING.md +56 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/SECURITY.md +30 -0
- data/docs/_advanced/rails-integration.md +199 -0
- data/docs/_advanced/security-and-deployment.md +204 -0
- data/docs/_core_features/approval-and-resume.md +62 -0
- data/docs/_core_features/input-validation.md +64 -0
- data/docs/_core_features/reconnects.md +42 -0
- data/docs/_core_features/streaming-runs.md +49 -0
- data/docs/_getting_started/configuration.md +57 -0
- data/docs/_getting_started/getting-started.md +52 -0
- data/docs/_getting_started/overview.md +189 -0
- data/docs/_getting_started/whats-new-in-0-1.md +41 -0
- data/docs/_reference/api-reference.md +26 -0
- data/docs/_reference/compatibility.md +130 -0
- data/docs/_reference/protocol-coverage.md +175 -0
- data/docs/_reference/release-history.md +22 -0
- data/docs/_reference/releasing.md +54 -0
- data/docs/_reference/third-party-notices.md +82 -0
- data/docs/_reference/upgrading.md +42 -0
- data/docs/index.md +53 -0
- data/lib/generators/ruby_llm/ag_ui/install_generator.rb +37 -0
- data/lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt +24 -0
- data/lib/generators/ruby_llm/ag_ui/templates/initializer.rb.tt +24 -0
- data/lib/ruby_llm/ag_ui/canonical.rb +40 -0
- data/lib/ruby_llm/ag_ui/configuration.rb +113 -0
- data/lib/ruby_llm/ag_ui/coverage.rb +163 -0
- data/lib/ruby_llm/ag_ui/emitter.rb +119 -0
- data/lib/ruby_llm/ag_ui/error.rb +69 -0
- data/lib/ruby_llm/ag_ui/error_sanitizer.rb +69 -0
- data/lib/ruby_llm/ag_ui/input.rb +299 -0
- data/lib/ruby_llm/ag_ui/interrupt.rb +207 -0
- data/lib/ruby_llm/ag_ui/mapper.rb +333 -0
- data/lib/ruby_llm/ag_ui/message_parser.rb +127 -0
- data/lib/ruby_llm/ag_ui/protocol.rb +130 -0
- data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +119 -0
- data/lib/ruby_llm/ag_ui/rails/railtie.rb +29 -0
- data/lib/ruby_llm/ag_ui/rails/record.rb +51 -0
- data/lib/ruby_llm/ag_ui/rails/streaming.rb +166 -0
- data/lib/ruby_llm/ag_ui/reconnect.rb +86 -0
- data/lib/ruby_llm/ag_ui/redaction.rb +58 -0
- data/lib/ruby_llm/ag_ui/redemption.rb +62 -0
- data/lib/ruby_llm/ag_ui/reservation.rb +56 -0
- data/lib/ruby_llm/ag_ui/resume.rb +108 -0
- data/lib/ruby_llm/ag_ui/resume_entry.rb +49 -0
- data/lib/ruby_llm/ag_ui/run.rb +177 -0
- data/lib/ruby_llm/ag_ui/run_record.rb +229 -0
- data/lib/ruby_llm/ag_ui/sse_writer.rb +119 -0
- data/lib/ruby_llm/ag_ui/store/base.rb +123 -0
- data/lib/ruby_llm/ag_ui/store/memory_store.rb +123 -0
- data/lib/ruby_llm/ag_ui/validation.rb +63 -0
- data/lib/ruby_llm/ag_ui/version.rb +8 -0
- data/lib/ruby_llm/ag_ui.rb +65 -0
- metadata +223 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module AgUI
|
|
5
|
+
# Writes 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
|