agent2agent 1.1.0 → 2.0.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/a2a/agent.rb +98 -241
  3. data/lib/a2a/{faraday → client}/middleware/json_rpc/request.rb +10 -8
  4. data/lib/a2a/{faraday → client}/middleware/json_rpc/response.rb +10 -9
  5. data/lib/a2a/{faraday → client}/middleware/rest/request.rb +30 -20
  6. data/lib/a2a/{faraday → client}/middleware/rest/response.rb +12 -8
  7. data/lib/a2a/{faraday → client}/middleware/schema_request.rb +9 -8
  8. data/lib/a2a/client.rb +40 -40
  9. data/lib/a2a/errors/json_rpc_error.rb +1 -2
  10. data/lib/a2a/errors/rest_error.rb +1 -2
  11. data/lib/a2a/errors.rb +1 -2
  12. data/lib/a2a/protocol/json_schema/definition.rb +276 -0
  13. data/lib/a2a/protocol/json_schema/validation_error.rb +132 -0
  14. data/lib/a2a/{schema.rb → protocol/json_schema.rb} +211 -208
  15. data/lib/a2a/protocol/protobuf.rb +447 -0
  16. data/lib/a2a/protocol.rb +31 -0
  17. data/lib/a2a/server/bindings/grpc.rb +37 -0
  18. data/lib/a2a/server/bindings/json_rpc.rb +29 -9
  19. data/lib/a2a/server/bindings/rest.rb +26 -13
  20. data/lib/a2a/server/middleware/extract_message.rb +145 -0
  21. data/lib/a2a/{middleware → server/middleware}/fetch_task.rb +82 -89
  22. data/lib/a2a/{middleware → server/middleware}/limit_history_length.rb +40 -49
  23. data/lib/a2a/{middleware → server/middleware}/limit_pagination_size.rb +36 -42
  24. data/lib/a2a/server/middleware/sse_stream.rb +236 -0
  25. data/lib/a2a/{sse → server/sse}/event_parser.rb +70 -69
  26. data/lib/a2a/server/sse/json_rpc_stream.rb +89 -0
  27. data/lib/a2a/server/sse/rest_stream.rb +63 -0
  28. data/lib/a2a/server/sse/stream.rb +261 -0
  29. data/lib/a2a/server/triage.rb +35 -4
  30. data/lib/a2a/server/well_known.rb +27 -0
  31. data/lib/a2a/server.rb +44 -58
  32. data/lib/a2a/test_helpers.rb +34 -75
  33. data/lib/a2a/version.rb +1 -1
  34. data/lib/a2a.rb +7 -11
  35. data/lib/traces/provider/a2a/agent.rb +25 -0
  36. data/lib/traces/provider/a2a.rb +1 -1
  37. metadata +39 -37
  38. data/lib/a2a/middleware/extract_message.rb +0 -120
  39. data/lib/a2a/middleware/sse_stream.rb +0 -235
  40. data/lib/a2a/proto.rb +0 -444
  41. data/lib/a2a/schema/definition.rb +0 -148
  42. data/lib/a2a/schema/validation_error.rb +0 -129
  43. data/lib/a2a/server/dispatcher.rb +0 -127
  44. data/lib/a2a/sse/json_rpc_stream.rb +0 -88
  45. data/lib/a2a/sse/rest_stream.rb +0 -62
  46. data/lib/a2a/sse/stream.rb +0 -257
  47. data/lib/traces/provider/a2a/server/dispatcher.rb +0 -26
  48. /data/lib/a2a/{middleware.rb → server/middleware.rb} +0 -0
  49. /data/lib/a2a/{sse.rb → server/sse.rb} +0 -0
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Protocol
8
+ # Parses the A2A protocol's .proto file to extract service operations
9
+ # and bridge them to Schema definition classes for request/response
10
+ # validation.
11
+ #
12
+ # The .proto file is the single normative source for the A2A protocol.
13
+ # This module extracts the service definition (RPC names, request/response
14
+ # types, streaming flags, HTTP bindings) and connects each operation to
15
+ # the corresponding Schema[] class so callers can validate data:
16
+ #
17
+ # op = A2A::Protocol::Protobuf.operation("SendMessage")
18
+ # op.request_schema.new(params).valid!
19
+ # op.response_schema.new(result).valid!
20
+ #
21
+ # A2A::Protocol::Protobuf.operations
22
+ # #=> [Operation("SendMessage", ...), Operation("GetTask", ...), ...]
23
+ #
24
+ module Protobuf
25
+ PROTO_PATH = File.expand_path("../../../data/a2a.proto", __dir__).freeze
26
+
27
+ # --- Data classes ---------------------------------------------------
28
+
29
+ HttpBinding = Data.define(:verb, :path, :body)
30
+
31
+ class Operation
32
+ attr_reader :name, :request_type, :response_type,
33
+ :server_streaming, :http_bindings
34
+
35
+ def initialize(name:, request_type:, response_type:,
36
+ server_streaming:, http_bindings:)
37
+ @name = name
38
+ @request_type = request_type
39
+ @response_type = response_type
40
+ @server_streaming = server_streaming
41
+ @http_bindings = http_bindings
42
+ end
43
+
44
+ def server_streaming? = @server_streaming
45
+
46
+ # Bridge to Schema: convert proto PascalCase type name to
47
+ # the JSON schema title used by A2A::Protocol::JsonSchema[].
48
+ #
49
+ # "SendMessageRequest" => Schema["Send Message Request"]
50
+ #
51
+ def request_schema
52
+ A2A::Protocol::JsonSchema[pascal_to_title(request_type)]
53
+ end
54
+
55
+ def response_schema
56
+ return nil if response_type.include?(".") # google.protobuf.Empty
57
+ A2A::Protocol::JsonSchema[pascal_to_title(response_type)]
58
+ end
59
+
60
+ # JSON-RPC and gRPC method names are identical to the RPC name.
61
+ def json_rpc_method = name
62
+ def grpc_method = name
63
+
64
+ # REST binding from the primary (non-tenant) HTTP annotation.
65
+ def rest_verb = http_bindings.first.verb
66
+ def rest_path = http_bindings.first.path
67
+
68
+ def inspect
69
+ "#<Proto::Operation #{name} #{rest_verb.upcase} #{rest_path}>"
70
+ end
71
+
72
+ private
73
+
74
+ # "SendMessageRequest" => "Send Message Request"
75
+ def pascal_to_title(str)
76
+ str.gsub(/([A-Z])/) { " #{$1}" }.strip
77
+ end
78
+ end
79
+
80
+ # --- Module API -----------------------------------------------------
81
+
82
+ class << self
83
+ def operations
84
+ @operations ||= parse_operations
85
+ end
86
+
87
+ def operation(name)
88
+ operations.find { |op| op.name == name }
89
+ end
90
+
91
+ # Reset cached state (for tests).
92
+ def reset!
93
+ @operations = nil
94
+ end
95
+
96
+ private
97
+
98
+ def parse_operations
99
+ proto_text = File.read(PROTO_PATH)
100
+ service_block = extract_service_block(proto_text)
101
+ parse_rpcs(service_block)
102
+ end
103
+
104
+ # Extract the body of `service A2AService { ... }` from the proto text.
105
+ def extract_service_block(text)
106
+ start = text.index(/^service\s+\w+\s*\{/)
107
+ return "" unless start
108
+
109
+ depth = 0
110
+ pos = text.index("{", start)
111
+ body_start = pos + 1
112
+
113
+ (pos...text.length).each do |i|
114
+ case text[i]
115
+ when "{" then depth += 1
116
+ when "}"
117
+ depth -= 1
118
+ return text[body_start...i] if depth == 0
119
+ end
120
+ end
121
+
122
+ ""
123
+ end
124
+
125
+ # Parse all `rpc` definitions from the service block text.
126
+ def parse_rpcs(block)
127
+ ops = []
128
+
129
+ rpc_chunks = block.split(/(?=^\s*rpc\s)/m).select { |c| c.match?(/^\s*rpc\s/) }
130
+
131
+ rpc_chunks.each do |chunk|
132
+ sig = chunk.match(
133
+ /rpc\s+(\w+)\s*\(\s*(\w[\w.]*)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w[\w.]*)\s*\)/
134
+ )
135
+ next unless sig
136
+
137
+ name = sig[1]
138
+ request_type = sig[2]
139
+ streaming = !sig[3].nil?
140
+ response_type = sig[4]
141
+
142
+ http_bindings = parse_http_bindings(chunk)
143
+
144
+ ops << Operation.new(
145
+ name: name,
146
+ request_type: request_type,
147
+ response_type: response_type,
148
+ server_streaming: streaming,
149
+ http_bindings: http_bindings,
150
+ )
151
+ end
152
+
153
+ ops
154
+ end
155
+
156
+ def parse_http_bindings(chunk)
157
+ bindings = []
158
+
159
+ http_start = chunk.index("google.api.http")
160
+ return bindings unless http_start
161
+
162
+ eq_brace = chunk.index("{", http_start)
163
+ return bindings unless eq_brace
164
+
165
+ block_text = extract_braced_block(chunk, eq_brace)
166
+ return bindings unless block_text
167
+
168
+ primary = parse_single_binding(block_text)
169
+ bindings << primary if primary
170
+
171
+ scan_additional_bindings(block_text).each do |ab_text|
172
+ binding = parse_single_binding(ab_text)
173
+ bindings << binding if binding
174
+ end
175
+
176
+ bindings
177
+ end
178
+
179
+ def extract_braced_block(text, open_pos)
180
+ depth = 0
181
+ body_start = open_pos + 1
182
+
183
+ (open_pos...text.length).each do |i|
184
+ case text[i]
185
+ when "{" then depth += 1
186
+ when "}"
187
+ depth -= 1
188
+ return text[body_start...i] if depth == 0
189
+ end
190
+ end
191
+
192
+ nil
193
+ end
194
+
195
+ def parse_single_binding(text)
196
+ verb_match = text.match(/\b(get|post|put|patch|delete):\s*"([^"]+)"/)
197
+ return nil unless verb_match
198
+
199
+ verb = verb_match[1]
200
+ path = verb_match[2]
201
+
202
+ body_match = text.match(/\bbody:\s*"([^"]*)"/)
203
+ body = body_match ? body_match[1] : nil
204
+
205
+ HttpBinding.new(verb: verb, path: path, body: body)
206
+ end
207
+
208
+ def scan_additional_bindings(text)
209
+ blocks = []
210
+ search_from = 0
211
+
212
+ while (ab_pos = text.index("additional_bindings", search_from))
213
+ brace_pos = text.index("{", ab_pos)
214
+ break unless brace_pos
215
+
216
+ inner = extract_braced_block(text, brace_pos)
217
+ if inner
218
+ blocks << inner
219
+ search_from = brace_pos + inner.length + 2
220
+ else
221
+ break
222
+ end
223
+ end
224
+
225
+ blocks
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+
232
+ __END__
233
+ describe "A2A::Protocol::Protobuf" do
234
+ proto = A2A::Protocol::Protobuf
235
+
236
+ it "finds 11 operations" do
237
+ proto.operations.size.should == 11
238
+ end
239
+
240
+ it "finds all operations by name" do
241
+ expected = %w[
242
+ SendMessage SendStreamingMessage GetTask ListTasks CancelTask
243
+ SubscribeToTask CreateTaskPushNotificationConfig
244
+ GetTaskPushNotificationConfig ListTaskPushNotificationConfigs
245
+ DeleteTaskPushNotificationConfig GetExtendedAgentCard
246
+ ]
247
+ proto.operations.map(&:name).sort.should == expected.sort
248
+ end
249
+
250
+ it "looks up an operation by name" do
251
+ proto.operation("SendMessage").should.not.be.nil
252
+ proto.operation("SendMessage").name.should == "SendMessage"
253
+ end
254
+
255
+ it "returns nil for unknown operation" do
256
+ proto.operation("NoSuchThing").should.be.nil
257
+ end
258
+
259
+ it "SendMessage request type" do
260
+ proto.operation("SendMessage").request_type.should == "SendMessageRequest"
261
+ end
262
+
263
+ it "SendMessage response type" do
264
+ proto.operation("SendMessage").response_type.should == "SendMessageResponse"
265
+ end
266
+
267
+ it "GetTask returns Task" do
268
+ proto.operation("GetTask").response_type.should == "Task"
269
+ end
270
+
271
+ it "CancelTask returns Task" do
272
+ proto.operation("CancelTask").response_type.should == "Task"
273
+ end
274
+
275
+ it "GetExtendedAgentCard returns AgentCard" do
276
+ proto.operation("GetExtendedAgentCard").response_type.should == "AgentCard"
277
+ end
278
+
279
+ it "DeleteTaskPushNotificationConfig returns google.protobuf.Empty" do
280
+ proto.operation("DeleteTaskPushNotificationConfig")
281
+ .response_type.should == "google.protobuf.Empty"
282
+ end
283
+
284
+ it "CreateTaskPushNotificationConfig request and response are both TaskPushNotificationConfig" do
285
+ op = proto.operation("CreateTaskPushNotificationConfig")
286
+ op.request_type.should == "TaskPushNotificationConfig"
287
+ op.response_type.should == "TaskPushNotificationConfig"
288
+ end
289
+
290
+ it "SendStreamingMessage is server-streaming" do
291
+ proto.operation("SendStreamingMessage").server_streaming?.should == true
292
+ end
293
+
294
+ it "SubscribeToTask is server-streaming" do
295
+ proto.operation("SubscribeToTask").server_streaming?.should == true
296
+ end
297
+
298
+ it "SendMessage is not server-streaming" do
299
+ proto.operation("SendMessage").server_streaming?.should == false
300
+ end
301
+
302
+ it "GetTask is not server-streaming" do
303
+ proto.operation("GetTask").server_streaming?.should == false
304
+ end
305
+
306
+ it "SendMessage has POST /message:send" do
307
+ b = proto.operation("SendMessage").http_bindings.first
308
+ b.verb.should == "post"
309
+ b.path.should == "/message:send"
310
+ b.body.should == "*"
311
+ end
312
+
313
+ it "GetTask has GET /tasks/{id=*}" do
314
+ b = proto.operation("GetTask").http_bindings.first
315
+ b.verb.should == "get"
316
+ b.path.should == "/tasks/{id=*}"
317
+ b.body.should.be.nil
318
+ end
319
+
320
+ it "ListTasks has GET /tasks" do
321
+ b = proto.operation("ListTasks").http_bindings.first
322
+ b.verb.should == "get"
323
+ b.path.should == "/tasks"
324
+ end
325
+
326
+ it "CancelTask has POST /tasks/{id=*}:cancel" do
327
+ b = proto.operation("CancelTask").http_bindings.first
328
+ b.verb.should == "post"
329
+ b.path.should == "/tasks/{id=*}:cancel"
330
+ b.body.should == "*"
331
+ end
332
+
333
+ it "DeleteTaskPushNotificationConfig has DELETE verb" do
334
+ b = proto.operation("DeleteTaskPushNotificationConfig").http_bindings.first
335
+ b.verb.should == "delete"
336
+ end
337
+
338
+ it "each operation has at least 2 HTTP bindings (primary + tenant)" do
339
+ proto.operations.each do |op|
340
+ op.http_bindings.size.should >= 2
341
+ end
342
+ end
343
+
344
+ it "second binding is the tenant-prefixed variant" do
345
+ b = proto.operation("SendMessage").http_bindings[1]
346
+ b.path.should == "/{tenant}/message:send"
347
+ b.verb.should == "post"
348
+ end
349
+
350
+ it "GetTask tenant binding" do
351
+ b = proto.operation("GetTask").http_bindings[1]
352
+ b.path.should == "/{tenant}/tasks/{id=*}"
353
+ end
354
+
355
+ it "rest_verb returns the primary verb" do
356
+ proto.operation("GetTask").rest_verb.should == "get"
357
+ proto.operation("SendMessage").rest_verb.should == "post"
358
+ proto.operation("DeleteTaskPushNotificationConfig").rest_verb.should == "delete"
359
+ end
360
+
361
+ it "rest_path returns the primary path" do
362
+ proto.operation("GetTask").rest_path.should == "/tasks/{id=*}"
363
+ proto.operation("ListTasks").rest_path.should == "/tasks"
364
+ end
365
+
366
+ it "json_rpc_method equals the operation name" do
367
+ proto.operations.each do |op|
368
+ op.json_rpc_method.should == op.name
369
+ end
370
+ end
371
+
372
+ it "grpc_method equals the operation name" do
373
+ proto.operations.each do |op|
374
+ op.grpc_method.should == op.name
375
+ end
376
+ end
377
+
378
+ it "request_schema returns the matching Schema class" do
379
+ op = proto.operation("SendMessage")
380
+ op.request_schema.should == A2A::Protocol::JsonSchema["Send Message Request"]
381
+ end
382
+
383
+ it "response_schema returns the matching Schema class" do
384
+ op = proto.operation("SendMessage")
385
+ op.response_schema.should == A2A::Protocol::JsonSchema["Send Message Response"]
386
+ end
387
+
388
+ it "response_schema for GetTask returns Task schema" do
389
+ op = proto.operation("GetTask")
390
+ op.response_schema.should == A2A::Protocol::JsonSchema["Task"]
391
+ end
392
+
393
+ it "response_schema returns nil for google.protobuf.Empty" do
394
+ op = proto.operation("DeleteTaskPushNotificationConfig")
395
+ op.response_schema.should.be.nil
396
+ end
397
+
398
+ it "every operation has a valid request_schema" do
399
+ proto.operations.each do |op|
400
+ op.request_schema.should.be.kind_of(Class)
401
+ (op.request_schema < A2A::Protocol::JsonSchema::Definition).should == true
402
+ end
403
+ end
404
+
405
+ it "every non-Empty operation has a valid response_schema" do
406
+ proto.operations.each do |op|
407
+ next if op.response_type.include?(".")
408
+ op.response_schema.should.be.kind_of(Class)
409
+ (op.response_schema < A2A::Protocol::JsonSchema::Definition).should == true
410
+ end
411
+ end
412
+
413
+ it "can build and validate a SendMessage request" do
414
+ op = proto.operation("SendMessage")
415
+ req = op.request_schema.new(
416
+ message: {
417
+ "messageId" => "msg-1",
418
+ "role" => "ROLE_USER",
419
+ "parts" => [{ "text" => "Hello" }]
420
+ }
421
+ )
422
+ req.valid?.should == true
423
+ end
424
+
425
+ it "can build and validate a GetTask request" do
426
+ op = proto.operation("GetTask")
427
+ req = op.request_schema.new(id: "task-123")
428
+ req.valid?.should == true
429
+ end
430
+
431
+ it "can build and validate a Task response" do
432
+ op = proto.operation("GetTask")
433
+ resp = op.response_schema.new(
434
+ id: "task-123",
435
+ context_id: "ctx-456",
436
+ status: { "state" => "TASK_STATE_SUBMITTED" }
437
+ )
438
+ resp.valid?.should == true
439
+ end
440
+
441
+ it "has a useful inspect" do
442
+ op = proto.operation("SendMessage")
443
+ op.inspect.should.include?("SendMessage")
444
+ op.inspect.should.include?("POST")
445
+ op.inspect.should.include?("/message:send")
446
+ end
447
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ module A2A
6
+ # Namespace for the normative A2A protocol sources.
7
+ #
8
+ # The protocol is defined by two bundled artifacts:
9
+ #
10
+ # Protobuf — parses data/a2a.proto (the single normative source) into
11
+ # service operations with request/response types, streaming
12
+ # flags, and HTTP bindings.
13
+ # JsonSchema — loads data/a2a.json and generates schema-validated
14
+ # Definition classes for each protocol type.
15
+ #
16
+ # Protobuf bridges each operation to its JsonSchema class, so callers can
17
+ # validate protocol payloads end to end:
18
+ #
19
+ # op = A2A::Protocol::Protobuf.operation("SendMessage")
20
+ # op.request_schema.new(params).valid!
21
+ #
22
+ # A2A::Protocol::JsonSchema["Agent Card"].new(card).valid!
23
+ #
24
+ module Protocol
25
+ end
26
+ end
27
+
28
+ require "a2a/protocol/protobuf"
29
+ require "a2a/protocol/json_schema"
30
+ require "a2a/protocol/json_schema/definition"
31
+ require "a2a/protocol/json_schema/validation_error"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ class Server
8
+ module Bindings
9
+ # Rack middleware reserving a path prefix (default "/grpc") for the
10
+ # A2A gRPC protocol binding. Requests under the prefix get 501 Not
11
+ # Implemented; all other requests pass through untouched.
12
+ #
13
+ class Grpc
14
+ def initialize(app, path_prefix: "/grpc")
15
+ @app = app
16
+ @path_prefix = path_prefix
17
+ end
18
+
19
+ def call(env)
20
+ return @app.call(env) unless claims?(env["PATH_INFO"])
21
+
22
+ body = { "type" => "error", "title" => "gRPC binding is not implemented", "status" => 501 }
23
+ [501, { "content-type" => "application/problem+json" }, [JSON.generate(body)]]
24
+ end
25
+
26
+ private
27
+
28
+ def claims?(path)
29
+ return true if path == @path_prefix
30
+
31
+ prefix = @path_prefix.end_with?("/") ? @path_prefix : "#{@path_prefix}/"
32
+ path.start_with?(prefix)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -2,13 +2,17 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "a2a"
5
- require "a2a/sse"
5
+ require "a2a/server/sse"
6
6
 
7
7
  module A2A
8
8
  class Server
9
9
  module Bindings
10
10
  # Rack middleware implementing the A2A JSON-RPC 2.0 protocol binding.
11
11
  #
12
+ # Claims requests under its path prefix (default "/", i.e. everything)
13
+ # that were not already claimed by a binding earlier in the stack
14
+ # (env["a2a.body"] unset); all other requests pass through untouched.
15
+ #
12
16
  # Strips the JSON-RPC envelope from the inbound request, setting
13
17
  # env keys for the method name, request id, and parsed params.
14
18
  # Calls downstream. On return, wraps env["a2a.result"] back into
@@ -21,12 +25,17 @@ module A2A
21
25
  # with backpressure — no Thread::Queue, no #each polling.
22
26
  #
23
27
  class JsonRpc
24
- def initialize(app)
28
+ def initialize(app, path_prefix: "/")
25
29
  @app = app
30
+ @path_prefix = path_prefix
26
31
  end
27
32
 
28
33
  def call(env)
29
- req = Rack::Request.new(env)
34
+ return @app.call(env) if env.key?("a2a.body")
35
+
36
+ req = Rack::Request.new(env)
37
+ return @app.call(env) unless claims?(req.path_info)
38
+
30
39
  body = req.body.read
31
40
  req.body.rewind
32
41
 
@@ -58,7 +67,7 @@ module A2A
58
67
  # Check if handler set up a streaming response.
59
68
  # The stream is an SSE::Stream (Protocol::HTTP::Body::Readable).
60
69
  if (stream = env["a2a.stream"])
61
- return [200, A2A::SSE::Stream.headers, stream]
70
+ return [200, A2A::Server::SSE::Stream.headers, stream]
62
71
  end
63
72
 
64
73
  success_response(id, result)
@@ -66,6 +75,17 @@ module A2A
66
75
 
67
76
  private
68
77
 
78
+ def claims?(path)
79
+ # A request to the exact mount point of a mounted app (e.g. Rails
80
+ # `mount server, at: "/agent1"`) arrives with an empty PATH_INFO.
81
+ path = "/" if path.to_s.empty?
82
+
83
+ return true if path == @path_prefix
84
+
85
+ prefix = @path_prefix.end_with?("/") ? @path_prefix : "#{@path_prefix}/"
86
+ path.start_with?(prefix)
87
+ end
88
+
69
89
  def success_response(id, result)
70
90
  body = result.respond_to?(:to_h) ? result.to_h : (result || {})
71
91
  [200, { "content-type" => "application/json" },
@@ -83,14 +103,14 @@ module A2A
83
103
  end
84
104
  end
85
105
 
86
- test do
106
+ __END__
107
+ describe "A2A::Server::Bindings::JsonRpc" do
87
108
  require "a2a/test_helpers"
88
109
 
89
- server = A2A::Server.new(agent_card: { "name" => "Test" })
90
- server.register(A2A::TestHelpers.stub_agent)
110
+ server = A2A::TestHelpers.stub_agent(agent_card: { "name" => "Test" })
91
111
  rack = Rack::MockRequest.new(server)
92
112
 
93
- A2A::Proto.operations.each do |op|
113
+ A2A::Protocol::Protobuf.operations.each do |op|
94
114
  it "#{op.json_rpc_method} returns valid #{op.response_type}" do
95
115
  body = JSON.generate({
96
116
  jsonrpc: "2.0",
@@ -99,7 +119,7 @@ test do
99
119
  params: {}
100
120
  })
101
121
 
102
- response = rack.post("/a2a", input: body, "CONTENT_TYPE" => "application/json")
122
+ response = rack.post("/", input: body, "CONTENT_TYPE" => "application/json")
103
123
  parsed = JSON.parse(response.body)
104
124
 
105
125
  parsed["error"].should.be.nil
@@ -2,16 +2,18 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "a2a"
5
- require "a2a/sse"
5
+ require "a2a/server/sse"
6
6
 
7
7
  module A2A
8
8
  class Server
9
9
  module Bindings
10
10
  # Rack middleware implementing the A2A HTTP+JSON/REST protocol binding.
11
11
  #
12
- # Extracts the HTTP verb, path, and request body/params into env keys.
13
- # Calls downstream. On return, wraps env["a2a.result"] into a REST
14
- # response with content-type application/a2a+json.
12
+ # Claims requests under its path prefix (default "/rest"); all other
13
+ # requests pass through untouched. For claimed requests, extracts the
14
+ # HTTP verb, path (with the prefix stripped), and request body/params
15
+ # into env keys. Calls downstream. On return, wraps env["a2a.result"]
16
+ # into a REST response with content-type application/a2a+json.
15
17
  #
16
18
  # Streaming operations:
17
19
  # When the handler sets env["a2a.stream"] to an SSE::Stream (which is
@@ -19,15 +21,19 @@ module A2A
19
21
  # no wrapping, no #each conversion, true async with backpressure.
20
22
  #
21
23
  class Rest
22
- def initialize(app)
24
+ def initialize(app, path_prefix: "/rest")
23
25
  @app = app
26
+ @path_prefix = path_prefix
24
27
  end
25
28
 
26
29
  def call(env)
27
- req = Rack::Request.new(env)
30
+ req = Rack::Request.new(env)
31
+ path = req.path_info
32
+
33
+ return @app.call(env) unless claims?(path)
28
34
 
29
35
  env["a2a.verb"] = req.request_method.downcase
30
- env["a2a.path"] = req.path_info
36
+ env["a2a.path"] = path.delete_prefix(@path_prefix)
31
37
 
32
38
  params = {}
33
39
  if req.post? || req.put? || req.patch?
@@ -51,7 +57,7 @@ module A2A
51
57
  # Check if handler set up a streaming response.
52
58
  # The stream is an SSE::Stream (Protocol::HTTP::Body::Readable).
53
59
  if (stream = env["a2a.stream"])
54
- return [200, A2A::SSE::Stream.headers, stream]
60
+ return [200, A2A::Server::SSE::Stream.headers, stream]
55
61
  end
56
62
 
57
63
  success_response(result)
@@ -59,6 +65,13 @@ module A2A
59
65
 
60
66
  private
61
67
 
68
+ def claims?(path)
69
+ return true if path == @path_prefix
70
+
71
+ prefix = @path_prefix.end_with?("/") ? @path_prefix : "#{@path_prefix}/"
72
+ path.start_with?(prefix)
73
+ end
74
+
62
75
  def success_response(result)
63
76
  body = result.respond_to?(:to_h) ? result.to_h : (result || {})
64
77
  [200, { "content-type" => "application/a2a+json" },
@@ -76,17 +89,17 @@ module A2A
76
89
  end
77
90
  end
78
91
 
79
- test do
92
+ __END__
93
+ describe "A2A::Server::Bindings::REST" do
80
94
  require "a2a/test_helpers"
81
95
 
82
- server = A2A::Server.new(agent_card: { "name" => "Test" })
83
- server.register(A2A::TestHelpers.stub_agent)
96
+ server = A2A::TestHelpers.stub_agent(agent_card: { "name" => "Test" })
84
97
  rack = Rack::MockRequest.new(server)
85
98
 
86
- A2A::Proto.operations.each do |op|
99
+ A2A::Protocol::Protobuf.operations.each do |op|
87
100
  it "#{op.rest_verb.upcase} #{op.rest_path} returns valid #{op.response_type}" do
88
101
  # Build request path, replacing {id=*} etc with a placeholder value
89
- path = op.rest_path.gsub(/\{[^}]+\}/, "test-id")
102
+ path = "/rest" + op.rest_path.gsub(/\{[^}]+\}/, "test-id")
90
103
 
91
104
  input = nil
92
105
  if op.http_bindings.first.body