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.
- checksums.yaml +4 -4
- data/lib/a2a/agent.rb +98 -241
- data/lib/a2a/{faraday → client}/middleware/json_rpc/request.rb +10 -8
- data/lib/a2a/{faraday → client}/middleware/json_rpc/response.rb +10 -9
- data/lib/a2a/{faraday → client}/middleware/rest/request.rb +30 -20
- data/lib/a2a/{faraday → client}/middleware/rest/response.rb +12 -8
- data/lib/a2a/{faraday → client}/middleware/schema_request.rb +9 -8
- data/lib/a2a/client.rb +40 -40
- data/lib/a2a/errors/json_rpc_error.rb +1 -2
- data/lib/a2a/errors/rest_error.rb +1 -2
- data/lib/a2a/errors.rb +1 -2
- data/lib/a2a/protocol/json_schema/definition.rb +276 -0
- data/lib/a2a/protocol/json_schema/validation_error.rb +132 -0
- data/lib/a2a/{schema.rb → protocol/json_schema.rb} +211 -208
- data/lib/a2a/protocol/protobuf.rb +447 -0
- data/lib/a2a/protocol.rb +31 -0
- data/lib/a2a/server/bindings/grpc.rb +37 -0
- data/lib/a2a/server/bindings/json_rpc.rb +29 -9
- data/lib/a2a/server/bindings/rest.rb +26 -13
- data/lib/a2a/server/middleware/extract_message.rb +145 -0
- data/lib/a2a/{middleware → server/middleware}/fetch_task.rb +82 -89
- data/lib/a2a/{middleware → server/middleware}/limit_history_length.rb +40 -49
- data/lib/a2a/{middleware → server/middleware}/limit_pagination_size.rb +36 -42
- data/lib/a2a/server/middleware/sse_stream.rb +236 -0
- data/lib/a2a/{sse → server/sse}/event_parser.rb +70 -69
- data/lib/a2a/server/sse/json_rpc_stream.rb +89 -0
- data/lib/a2a/server/sse/rest_stream.rb +63 -0
- data/lib/a2a/server/sse/stream.rb +261 -0
- data/lib/a2a/server/triage.rb +35 -4
- data/lib/a2a/server/well_known.rb +27 -0
- data/lib/a2a/server.rb +44 -58
- data/lib/a2a/test_helpers.rb +34 -75
- data/lib/a2a/version.rb +1 -1
- data/lib/a2a.rb +7 -11
- data/lib/traces/provider/a2a/agent.rb +25 -0
- data/lib/traces/provider/a2a.rb +1 -1
- metadata +39 -37
- data/lib/a2a/middleware/extract_message.rb +0 -120
- data/lib/a2a/middleware/sse_stream.rb +0 -235
- data/lib/a2a/proto.rb +0 -444
- data/lib/a2a/schema/definition.rb +0 -148
- data/lib/a2a/schema/validation_error.rb +0 -129
- data/lib/a2a/server/dispatcher.rb +0 -127
- data/lib/a2a/sse/json_rpc_stream.rb +0 -88
- data/lib/a2a/sse/rest_stream.rb +0 -62
- data/lib/a2a/sse/stream.rb +0 -257
- data/lib/traces/provider/a2a/server/dispatcher.rb +0 -26
- /data/lib/a2a/{middleware.rb → server/middleware.rb} +0 -0
- /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
|
data/lib/a2a/protocol.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
+
__END__
|
|
107
|
+
describe "A2A::Server::Bindings::JsonRpc" do
|
|
87
108
|
require "a2a/test_helpers"
|
|
88
109
|
|
|
89
|
-
server = A2A::
|
|
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::
|
|
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("/
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
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"] =
|
|
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
|
-
|
|
92
|
+
__END__
|
|
93
|
+
describe "A2A::Server::Bindings::REST" do
|
|
80
94
|
require "a2a/test_helpers"
|
|
81
95
|
|
|
82
|
-
server = A2A::
|
|
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::
|
|
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
|