a2a-rails 0.2.0 → 0.3.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.
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "client/agent_card_resolver"
5
+ require_relative "client/public_types"
6
+ require_relative "client/codec"
7
+
8
+ module A2A
9
+ module Rails
10
+ # Outbound A2A v1.0 Client. Does not require a local Agent, Task Store or
11
+ # server Runtime. This is an unreleased implementation; the production
12
+ # release gates in docs/design/a2a-client-public-api.md still apply.
13
+ class Client
14
+ def initialize(agent_card_url:, allowed_origins:, authorization: nil,
15
+ credential_origin: nil, card_authorization: nil,
16
+ card_credential_origin: nil, open_timeout: 3,
17
+ read_timeout: 10, total_timeout: 15)
18
+ validate_callback!(authorization)
19
+ validate_callback!(card_authorization)
20
+ @policy = OutboundPolicy.new(allowed_origins: allowed_origins)
21
+ card_uri = @policy.validate_url!(agent_card_url)
22
+ # Default each token audience to the explicitly configured card
23
+ # origin. When RPC is advertised on a different origin, callers must
24
+ # explicitly bind the RPC credential to that origin.
25
+ card_origin = origin_for(card_uri)
26
+ rpc_origin = credential_origin || (authorization && card_origin)
27
+ discovery_origin = card_credential_origin || (card_authorization && card_origin)
28
+ check_credential_binding!(authorization, rpc_origin)
29
+ check_credential_binding!(card_authorization, discovery_origin)
30
+
31
+ transport = PinnedHttpsTransport.new(
32
+ policy: @policy,
33
+ open_timeout: open_timeout,
34
+ read_timeout: read_timeout,
35
+ total_timeout: total_timeout
36
+ )
37
+ @resolver = AgentCardResolver.new(
38
+ agent_card_url: card_uri.to_s, policy: @policy, transport: transport,
39
+ authorization: authorization, credential_origin: rpc_origin,
40
+ card_authorization: card_authorization,
41
+ card_credential_origin: discovery_origin
42
+ )
43
+ rescue OutboundPolicy::RejectedTarget => error
44
+ raise ConfigurationError.new(error.reason), cause: nil
45
+ rescue PinnedHttpsTransport::Error => error
46
+ raise ConfigurationError.new(error.reason), cause: nil
47
+ end
48
+
49
+ def agent_card
50
+ with_errors(:agent_card) { Codec.decode(@resolver.discover.card) }
51
+ end
52
+
53
+ def send_message(message:, configuration: nil, metadata: nil)
54
+ with_errors(:send_message) do
55
+ validate_message!(message)
56
+ params = { "message" => Codec.encode(message) }
57
+ params["configuration"] = encode_hash!(configuration, :configuration) unless configuration.nil?
58
+ params["metadata"] = encode_hash!(metadata, :metadata, opaque: true) unless metadata.nil?
59
+ result = rpc(:send_message, "SendMessage", params)
60
+ task = result["task"]
61
+ direct = result["message"]
62
+ unless (task.is_a?(Hash) && direct.nil?) || (direct.is_a?(Hash) && task.nil?)
63
+ raise InvalidResponseError.new(:invalid_send_result, operation: :send_message)
64
+ end
65
+
66
+ if task
67
+ SendResult.new(kind: :task, task: normalize_task!(task, :send_message), message: nil).freeze
68
+ else
69
+ SendResult.new(kind: :message, task: nil, message: normalize_message!(direct, :send_message)).freeze
70
+ end
71
+ end
72
+ end
73
+
74
+ def get_task(id:, history_length: nil)
75
+ with_errors(:get_task) do
76
+ params = { "id" => required_text!(id, :id) }
77
+ params["historyLength"] = nonnegative_integer!(history_length, :history_length) unless history_length.nil?
78
+ normalize_task!(rpc(:get_task, "GetTask", params), :get_task)
79
+ end
80
+ end
81
+
82
+ def list_tasks(context_id: nil, status: nil, page_size: nil, page_token: nil,
83
+ history_length: nil, status_timestamp_after: nil, include_artifacts: nil)
84
+ with_errors(:list_tasks) do
85
+ params = {}
86
+ params["contextId"] = required_text!(context_id, :context_id) unless context_id.nil?
87
+ params["status"] = required_text!(status, :status) unless status.nil?
88
+ unless page_size.nil?
89
+ raise InvalidInputError, :page_size unless page_size.is_a?(Integer) && page_size.between?(1, 100)
90
+ params["pageSize"] = page_size
91
+ end
92
+ params["pageToken"] = string_value!(page_token, :page_token) unless page_token.nil?
93
+ params["historyLength"] = nonnegative_integer!(history_length, :history_length) unless history_length.nil?
94
+ params["statusTimestampAfter"] = required_text!(status_timestamp_after, :status_timestamp_after) unless status_timestamp_after.nil?
95
+ unless include_artifacts.nil?
96
+ raise InvalidInputError, :include_artifacts unless include_artifacts == true || include_artifacts == false
97
+ params["includeArtifacts"] = include_artifacts
98
+ end
99
+
100
+ raw = rpc(:list_tasks, "ListTasks", params)
101
+ unless raw["tasks"].is_a?(Array) &&
102
+ raw["nextPageToken"].is_a?(String) &&
103
+ raw["pageSize"].is_a?(Integer) && raw["pageSize"] >= 0 &&
104
+ raw["totalSize"].is_a?(Integer) && raw["totalSize"] >= 0
105
+ raise InvalidResponseError.new(:invalid_page, operation: :list_tasks)
106
+ end
107
+ tasks = raw["tasks"].map { |value| normalize_task!(value, :list_tasks) }.freeze
108
+ ListResult.new(tasks: tasks,
109
+ next_page_token: raw.fetch("nextPageToken").dup.freeze,
110
+ page_size: raw.fetch("pageSize"),
111
+ total_size: raw.fetch("totalSize")).freeze
112
+ end
113
+ end
114
+
115
+ def cancel_task(id:, metadata: nil)
116
+ with_errors(:cancel_task) do
117
+ params = { "id" => required_text!(id, :id) }
118
+ params["metadata"] = encode_hash!(metadata, :metadata, opaque: true) unless metadata.nil?
119
+ normalize_task!(rpc(:cancel_task, "CancelTask", params), :cancel_task)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def rpc(operation, name, params)
126
+ @resolver.rpc(method: name, params: params, id: SecureRandom.uuid)
127
+ end
128
+
129
+ def normalize_task!(value, operation)
130
+ unless value.is_a?(Hash) && text?(value["id"]) &&
131
+ value["status"].is_a?(Hash) && text?(value["status"]["state"])
132
+ raise InvalidResponseError.new(:invalid_task, operation: operation)
133
+ end
134
+
135
+ validate_remote_protocol_keys!(value, operation)
136
+ validate_remote_protocol_keys!(value["status"], operation)
137
+ status_message = value["status"]["message"]
138
+ validate_remote_message!(status_message, operation) unless status_message.nil?
139
+
140
+ artifacts = value["artifacts"]
141
+ unless artifacts.nil?
142
+ raise InvalidResponseError.new(:invalid_artifacts, operation: operation) unless artifacts.is_a?(Array)
143
+ artifacts.each { |artifact| validate_remote_artifact!(artifact, operation) }
144
+ end
145
+
146
+ history = value["history"]
147
+ unless history.nil?
148
+ raise InvalidResponseError.new(:invalid_history, operation: operation) unless history.is_a?(Array)
149
+ history.each { |message| validate_remote_message!(message, operation) }
150
+ end
151
+
152
+ decode_remote!(value, operation)
153
+ end
154
+
155
+ def normalize_message!(value, operation)
156
+ validate_remote_message!(value, operation)
157
+ decode_remote!(value, operation)
158
+ end
159
+
160
+ def validate_remote_message!(message, operation)
161
+ unless message.is_a?(Hash) && text?(message["messageId"]) &&
162
+ text?(message["role"]) && message["parts"].is_a?(Array) &&
163
+ !message["parts"].empty?
164
+ raise InvalidResponseError.new(:invalid_message, operation: operation)
165
+ end
166
+ validate_remote_protocol_keys!(message, operation)
167
+ message["parts"].each { |part| validate_remote_part!(part, operation) }
168
+ end
169
+
170
+ def validate_remote_artifact!(artifact, operation)
171
+ unless artifact.is_a?(Hash) && text?(artifact["artifactId"]) &&
172
+ artifact["parts"].is_a?(Array) && !artifact["parts"].empty?
173
+ raise InvalidResponseError.new(:invalid_artifact, operation: operation)
174
+ end
175
+ validate_remote_protocol_keys!(artifact, operation)
176
+ artifact["parts"].each { |part| validate_remote_part!(part, operation) }
177
+ end
178
+
179
+ def validate_remote_part!(part, operation)
180
+ kinds = %w[text raw url data]
181
+ unless part.is_a?(Hash) && kinds.count { |key| part.key?(key) } == 1
182
+ raise InvalidResponseError.new(:invalid_message_part, operation: operation)
183
+ end
184
+ validate_remote_protocol_keys!(part, operation)
185
+ type = kinds.find { |key| part.key?(key) }
186
+ valid_value = type == "data" ? part[type].is_a?(Hash) : part[type].is_a?(String)
187
+ unless valid_value
188
+ raise InvalidResponseError.new(:invalid_message_part, operation: operation)
189
+ end
190
+ end
191
+
192
+ def validate_remote_protocol_keys!(object, operation)
193
+ # Reject duplicate canonical/snake aliases in protocol structures.
194
+ # This is deliberately NOT recursive: custom metadata/data/extension
195
+ # keys remain opaque, even when they resemble protocol field names.
196
+ Codec::REVERSE.each do |wire_key, ruby_key|
197
+ next unless object.key?(wire_key) && object.key?(ruby_key)
198
+
199
+ raise InvalidResponseError.new(:ambiguous_protocol_key, operation: operation)
200
+ end
201
+ end
202
+
203
+ def decode_remote!(value, operation)
204
+ Codec.decode(value)
205
+ rescue InvalidInputError => error
206
+ # JSON from the remote endpoint is untrusted output, not caller input.
207
+ # Colliding protocol keys must become a sanitized response error.
208
+ raise InvalidResponseError.new(error.reason, operation: operation), cause: nil
209
+ end
210
+
211
+ def validate_message!(message)
212
+ unless message.is_a?(Hash) && text?(field(message, :message_id, "messageId")) &&
213
+ field(message, :role, "role") == "ROLE_USER" &&
214
+ (parts = field(message, :parts, "parts")).is_a?(Array) && !parts.empty?
215
+ raise InvalidInputError, :message
216
+ end
217
+ parts.each do |part|
218
+ raise InvalidInputError, :part unless part.is_a?(Hash)
219
+ count = %w[text raw url data].count { |key| part.key?(key) || part.key?(key.to_sym) }
220
+ raise InvalidInputError, :part_oneof unless count == 1
221
+ end
222
+ end
223
+
224
+ def field(hash, key, wire)
225
+ raise InvalidInputError, :ambiguous_protocol_key if hash.key?(key) && hash.key?(wire)
226
+ hash.key?(key) ? hash[key] : (hash.key?(wire) ? hash[wire] : hash[key.to_s])
227
+ end
228
+
229
+ def encode_hash!(value, label, opaque: false)
230
+ raise InvalidInputError, label unless value.is_a?(Hash)
231
+ Codec.encode(value, opaque: opaque)
232
+ end
233
+
234
+ def text?(value)
235
+ value.is_a?(String) && !value.empty? && !value.strip.empty?
236
+ end
237
+
238
+ def required_text!(value, name)
239
+ raise InvalidInputError, name unless text?(value)
240
+ value
241
+ end
242
+
243
+ def string_value!(value, name)
244
+ raise InvalidInputError, name unless value.is_a?(String)
245
+ value
246
+ end
247
+
248
+ def nonnegative_integer!(value, name)
249
+ raise InvalidInputError, name unless value.is_a?(Integer) && value >= 0
250
+ value
251
+ end
252
+
253
+ def origin_for(uri)
254
+ uri.port == 443 ? "https://#{uri.host}" : "https://#{uri.host}:#{uri.port}"
255
+ end
256
+
257
+ def validate_callback!(callback)
258
+ raise ConfigurationError, :credential_callback unless callback.nil? || callback.respond_to?(:call)
259
+ end
260
+
261
+ def check_credential_binding!(callback, origin)
262
+ if callback && (!origin.is_a?(String) || origin.empty?)
263
+ raise ConfigurationError, :credential_origin
264
+ end
265
+ raise ConfigurationError, :credential_origin unless callback || origin.nil?
266
+ end
267
+
268
+ def with_errors(operation)
269
+ yield
270
+ rescue Error
271
+ raise
272
+ rescue AgentCardResolver::UnsupportedInterface => error
273
+ raise UnsupportedInterfaceError.new(error.reason, operation: operation), cause: nil
274
+ rescue AgentCardResolver::RemoteError => error
275
+ raise RemoteError.new(error.code, operation: operation), cause: nil
276
+ rescue AgentCardResolver::InvalidRPC => error
277
+ raise InvalidResponseError.new(error.reason, operation: operation), cause: nil
278
+ rescue AgentCardResolver::InvalidCard, AgentCardResolver::Error => error
279
+ raise DiscoveryError.new(error.reason, operation: operation), cause: nil
280
+ rescue PinnedHttpsTransport::DeadlineExceeded
281
+ raise TimeoutError.new(operation: operation,
282
+ may_have_executed: %i[send_message cancel_task].include?(operation)), cause: nil
283
+ rescue PinnedHttpsTransport::HTTPError => error
284
+ if [401, 403].include?(error.status)
285
+ raise AuthenticationError.new(error.status, operation: operation), cause: nil
286
+ end
287
+ raise TransportError.new(:http_error, operation: operation), cause: nil
288
+ rescue PinnedHttpsTransport::InvalidResponse, PinnedHttpsTransport::ResponseTooLarge => error
289
+ raise InvalidResponseError.new(error.reason, operation: operation), cause: nil
290
+ rescue PinnedHttpsTransport::Error, OutboundPolicy::RejectedTarget => error
291
+ raise TransportError.new(error.reason, operation: operation), cause: nil
292
+ end
293
+ end
294
+ end
295
+ end
@@ -19,7 +19,7 @@ module A2A
19
19
  :task_store, :task_page_token_secret, :task_retention,
20
20
  :task_prune_batch_size, :max_tasks_per_owner,
21
21
  :max_task_history_entries, :max_task_artifacts,
22
- :task_execution_mode
22
+ :task_execution_mode, :server_enabled
23
23
 
24
24
  def initialize
25
25
  @agent = nil
@@ -37,6 +37,7 @@ module A2A
37
37
  @max_task_history_entries = DEFAULT_MAX_TASK_HISTORY_ENTRIES
38
38
  @max_task_artifacts = DEFAULT_MAX_TASK_ARTIFACTS
39
39
  @task_execution_mode = :sync
40
+ @server_enabled = true
40
41
  @logger_set = false
41
42
  end
42
43
 
@@ -53,7 +54,10 @@ module A2A
53
54
  end
54
55
 
55
56
  def validate!
56
- validate_agent_name!
57
+ unless @server_enabled == true || @server_enabled == false
58
+ raise ConfigurationError, "config.server_enabled must be true or false"
59
+ end
60
+ validate_agent_name! if @server_enabled
57
61
  normalized_public_base_url
58
62
  validate_task_execution_mode!(@task_execution_mode, "config.task_execution_mode")
59
63
  self
@@ -24,8 +24,11 @@ module A2A
24
24
  end
25
25
 
26
26
  initializer "a2a-rails.mount_engine" do |app|
27
+ # Defer this decision to route assembly, after host initializers have
28
+ # had a chance to set config.server_enabled = false. Existing Rails
29
+ # Server applications retain the default true behavior.
27
30
  app.routes.append do
28
- mount A2A::Rails::Engine => "/"
31
+ mount A2A::Rails::Engine => "/" if A2A::Rails.configuration.server_enabled
29
32
  end
30
33
  end
31
34
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A2A
4
4
  module Rails
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/a2a-rails.rb CHANGED
@@ -20,6 +20,7 @@ require_relative "a2a/rails/protocol/adapter"
20
20
  require_relative "a2a/rails/protocol/task_mapper"
21
21
  require_relative "a2a/rails/protocol/request_handler"
22
22
  require_relative "a2a/rails/protocol/agent2agent_adapter"
23
+ require_relative "a2a/rails/client"
23
24
  require_relative "a2a/rails/runtime"
24
25
  require_relative "a2a/rails/engine"
25
26
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: a2a-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - cuichangquan
@@ -67,6 +67,9 @@ dependencies:
67
67
  name: json
68
68
  requirement: !ruby/object:Gem::Requirement
69
69
  requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 2.19.9
70
73
  - - "<"
71
74
  - !ruby/object:Gem::Version
72
75
  version: '3'
@@ -74,6 +77,9 @@ dependencies:
74
77
  prerelease: false
75
78
  version_requirements: !ruby/object:Gem::Requirement
76
79
  requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 2.19.9
77
83
  - - "<"
78
84
  - !ruby/object:Gem::Version
79
85
  version: '3'
@@ -224,6 +230,12 @@ files:
224
230
  - lib/a2a/rails/agent_card/security.rb
225
231
  - lib/a2a/rails/agent_card/validator.rb
226
232
  - lib/a2a/rails/authentication.rb
233
+ - lib/a2a/rails/client.rb
234
+ - lib/a2a/rails/client/agent_card_resolver.rb
235
+ - lib/a2a/rails/client/codec.rb
236
+ - lib/a2a/rails/client/outbound_policy.rb
237
+ - lib/a2a/rails/client/pinned_https_transport.rb
238
+ - lib/a2a/rails/client/public_types.rb
227
239
  - lib/a2a/rails/configuration.rb
228
240
  - lib/a2a/rails/dispatcher.rb
229
241
  - lib/a2a/rails/engine.rb