agent2agent 1.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 +7 -0
- data/data/a2a.json +1961 -0
- data/data/a2a.proto +796 -0
- data/data/download-a2a-resources +4 -0
- data/data/spec.txt +3611 -0
- data/lib/a2a/agent.rb +243 -0
- data/lib/a2a/bindings/json_rpc.rb +110 -0
- data/lib/a2a/bindings/rest.rb +106 -0
- data/lib/a2a/client.rb +85 -0
- data/lib/a2a/proto.rb +444 -0
- data/lib/a2a/schema/definition.rb +114 -0
- data/lib/a2a/schema/validation_error.rb +129 -0
- data/lib/a2a/schema.rb +429 -0
- data/lib/a2a/server/cancel_task.rb +14 -0
- data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/dispatcher.rb +127 -0
- data/lib/a2a/server/env.rb +28 -0
- data/lib/a2a/server/get_extended_agent_card.rb +15 -0
- data/lib/a2a/server/get_task.rb +14 -0
- data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
- data/lib/a2a/server/list_tasks.rb +14 -0
- data/lib/a2a/server/send_message.rb +14 -0
- data/lib/a2a/server/send_streaming_message.rb +14 -0
- data/lib/a2a/server/subscribe_to_task.rb +14 -0
- data/lib/a2a/server/triage.rb +96 -0
- data/lib/a2a/server.rb +98 -0
- data/lib/a2a/sse/json_rpc_stream.rb +66 -0
- data/lib/a2a/sse/rest_stream.rb +50 -0
- data/lib/a2a/sse/stream.rb +129 -0
- data/lib/a2a/sse.rb +5 -0
- data/lib/a2a/store/processor.rb +136 -0
- data/lib/a2a/store/pub_sub.rb +149 -0
- data/lib/a2a/store/sqlite.rb +533 -0
- data/lib/a2a/store/webhooks.rb +94 -0
- data/lib/a2a/store.rb +6 -0
- data/lib/a2a/task_store.rb +315 -0
- data/lib/a2a/version.rb +5 -0
- data/lib/a2a.rb +26 -0
- metadata +216 -0
data/lib/a2a/proto.rb
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
# Parses the A2A protocol's .proto file to extract service operations
|
|
8
|
+
# and bridge them to Schema definition classes for request/response
|
|
9
|
+
# validation.
|
|
10
|
+
#
|
|
11
|
+
# The .proto file is the single normative source for the A2A protocol.
|
|
12
|
+
# This module extracts the service definition (RPC names, request/response
|
|
13
|
+
# types, streaming flags, HTTP bindings) and connects each operation to
|
|
14
|
+
# the corresponding Schema[] class so callers can validate data:
|
|
15
|
+
#
|
|
16
|
+
# op = A2A::Proto.operation("SendMessage")
|
|
17
|
+
# op.request_schema.new(params).valid!
|
|
18
|
+
# op.response_schema.new(result).valid!
|
|
19
|
+
#
|
|
20
|
+
# A2A::Proto.operations
|
|
21
|
+
# #=> [Operation("SendMessage", ...), Operation("GetTask", ...), ...]
|
|
22
|
+
#
|
|
23
|
+
module Proto
|
|
24
|
+
PROTO_PATH = File.expand_path("../../data/a2a.proto", __dir__).freeze
|
|
25
|
+
|
|
26
|
+
# --- Data classes ---------------------------------------------------
|
|
27
|
+
|
|
28
|
+
HttpBinding = Data.define(:verb, :path, :body)
|
|
29
|
+
|
|
30
|
+
class Operation
|
|
31
|
+
attr_reader :name, :request_type, :response_type,
|
|
32
|
+
:server_streaming, :http_bindings
|
|
33
|
+
|
|
34
|
+
def initialize(name:, request_type:, response_type:,
|
|
35
|
+
server_streaming:, http_bindings:)
|
|
36
|
+
@name = name
|
|
37
|
+
@request_type = request_type
|
|
38
|
+
@response_type = response_type
|
|
39
|
+
@server_streaming = server_streaming
|
|
40
|
+
@http_bindings = http_bindings
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def server_streaming? = @server_streaming
|
|
44
|
+
|
|
45
|
+
# Bridge to Schema: convert proto PascalCase type name to
|
|
46
|
+
# the JSON schema title used by A2A::Schema[].
|
|
47
|
+
#
|
|
48
|
+
# "SendMessageRequest" => Schema["Send Message Request"]
|
|
49
|
+
#
|
|
50
|
+
def request_schema
|
|
51
|
+
A2A::Schema[pascal_to_title(request_type)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def response_schema
|
|
55
|
+
return nil if response_type.include?(".") # google.protobuf.Empty
|
|
56
|
+
A2A::Schema[pascal_to_title(response_type)]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# JSON-RPC and gRPC method names are identical to the RPC name.
|
|
60
|
+
def json_rpc_method = name
|
|
61
|
+
def grpc_method = name
|
|
62
|
+
|
|
63
|
+
# REST binding from the primary (non-tenant) HTTP annotation.
|
|
64
|
+
def rest_verb = http_bindings.first.verb
|
|
65
|
+
def rest_path = http_bindings.first.path
|
|
66
|
+
|
|
67
|
+
def inspect
|
|
68
|
+
"#<Proto::Operation #{name} #{rest_verb.upcase} #{rest_path}>"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# "SendMessageRequest" => "Send Message Request"
|
|
74
|
+
def pascal_to_title(str)
|
|
75
|
+
str.gsub(/([A-Z])/) { " #{$1}" }.strip
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- Module API -----------------------------------------------------
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
def operations
|
|
83
|
+
@operations ||= parse_operations
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def operation(name)
|
|
87
|
+
operations.find { |op| op.name == name }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reset cached state (for tests).
|
|
91
|
+
def reset!
|
|
92
|
+
@operations = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def parse_operations
|
|
98
|
+
proto_text = File.read(PROTO_PATH)
|
|
99
|
+
service_block = extract_service_block(proto_text)
|
|
100
|
+
parse_rpcs(service_block)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Extract the body of `service A2AService { ... }` from the proto text.
|
|
104
|
+
def extract_service_block(text)
|
|
105
|
+
start = text.index(/^service\s+\w+\s*\{/)
|
|
106
|
+
return "" unless start
|
|
107
|
+
|
|
108
|
+
depth = 0
|
|
109
|
+
pos = text.index("{", start)
|
|
110
|
+
body_start = pos + 1
|
|
111
|
+
|
|
112
|
+
(pos...text.length).each do |i|
|
|
113
|
+
case text[i]
|
|
114
|
+
when "{" then depth += 1
|
|
115
|
+
when "}"
|
|
116
|
+
depth -= 1
|
|
117
|
+
return text[body_start...i] if depth == 0
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
""
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Parse all `rpc` definitions from the service block text.
|
|
125
|
+
def parse_rpcs(block)
|
|
126
|
+
ops = []
|
|
127
|
+
|
|
128
|
+
rpc_chunks = block.split(/(?=^\s*rpc\s)/m).select { |c| c.match?(/^\s*rpc\s/) }
|
|
129
|
+
|
|
130
|
+
rpc_chunks.each do |chunk|
|
|
131
|
+
sig = chunk.match(
|
|
132
|
+
/rpc\s+(\w+)\s*\(\s*(\w[\w.]*)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w[\w.]*)\s*\)/
|
|
133
|
+
)
|
|
134
|
+
next unless sig
|
|
135
|
+
|
|
136
|
+
name = sig[1]
|
|
137
|
+
request_type = sig[2]
|
|
138
|
+
streaming = !sig[3].nil?
|
|
139
|
+
response_type = sig[4]
|
|
140
|
+
|
|
141
|
+
http_bindings = parse_http_bindings(chunk)
|
|
142
|
+
|
|
143
|
+
ops << Operation.new(
|
|
144
|
+
name: name,
|
|
145
|
+
request_type: request_type,
|
|
146
|
+
response_type: response_type,
|
|
147
|
+
server_streaming: streaming,
|
|
148
|
+
http_bindings: http_bindings,
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
ops
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def parse_http_bindings(chunk)
|
|
156
|
+
bindings = []
|
|
157
|
+
|
|
158
|
+
http_start = chunk.index("google.api.http")
|
|
159
|
+
return bindings unless http_start
|
|
160
|
+
|
|
161
|
+
eq_brace = chunk.index("{", http_start)
|
|
162
|
+
return bindings unless eq_brace
|
|
163
|
+
|
|
164
|
+
block_text = extract_braced_block(chunk, eq_brace)
|
|
165
|
+
return bindings unless block_text
|
|
166
|
+
|
|
167
|
+
primary = parse_single_binding(block_text)
|
|
168
|
+
bindings << primary if primary
|
|
169
|
+
|
|
170
|
+
scan_additional_bindings(block_text).each do |ab_text|
|
|
171
|
+
binding = parse_single_binding(ab_text)
|
|
172
|
+
bindings << binding if binding
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
bindings
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def extract_braced_block(text, open_pos)
|
|
179
|
+
depth = 0
|
|
180
|
+
body_start = open_pos + 1
|
|
181
|
+
|
|
182
|
+
(open_pos...text.length).each do |i|
|
|
183
|
+
case text[i]
|
|
184
|
+
when "{" then depth += 1
|
|
185
|
+
when "}"
|
|
186
|
+
depth -= 1
|
|
187
|
+
return text[body_start...i] if depth == 0
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_single_binding(text)
|
|
195
|
+
verb_match = text.match(/\b(get|post|put|patch|delete):\s*"([^"]+)"/)
|
|
196
|
+
return nil unless verb_match
|
|
197
|
+
|
|
198
|
+
verb = verb_match[1]
|
|
199
|
+
path = verb_match[2]
|
|
200
|
+
|
|
201
|
+
body_match = text.match(/\bbody:\s*"([^"]*)"/)
|
|
202
|
+
body = body_match ? body_match[1] : nil
|
|
203
|
+
|
|
204
|
+
HttpBinding.new(verb: verb, path: path, body: body)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def scan_additional_bindings(text)
|
|
208
|
+
blocks = []
|
|
209
|
+
search_from = 0
|
|
210
|
+
|
|
211
|
+
while (ab_pos = text.index("additional_bindings", search_from))
|
|
212
|
+
brace_pos = text.index("{", ab_pos)
|
|
213
|
+
break unless brace_pos
|
|
214
|
+
|
|
215
|
+
inner = extract_braced_block(text, brace_pos)
|
|
216
|
+
if inner
|
|
217
|
+
blocks << inner
|
|
218
|
+
search_from = brace_pos + inner.length + 2
|
|
219
|
+
else
|
|
220
|
+
break
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
blocks
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
test do
|
|
231
|
+
proto = A2A::Proto
|
|
232
|
+
|
|
233
|
+
it "finds 11 operations" do
|
|
234
|
+
proto.operations.size.should == 11
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it "finds all operations by name" do
|
|
238
|
+
expected = %w[
|
|
239
|
+
SendMessage SendStreamingMessage GetTask ListTasks CancelTask
|
|
240
|
+
SubscribeToTask CreateTaskPushNotificationConfig
|
|
241
|
+
GetTaskPushNotificationConfig ListTaskPushNotificationConfigs
|
|
242
|
+
DeleteTaskPushNotificationConfig GetExtendedAgentCard
|
|
243
|
+
]
|
|
244
|
+
proto.operations.map(&:name).sort.should == expected.sort
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it "looks up an operation by name" do
|
|
248
|
+
proto.operation("SendMessage").should.not.be.nil
|
|
249
|
+
proto.operation("SendMessage").name.should == "SendMessage"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "returns nil for unknown operation" do
|
|
253
|
+
proto.operation("NoSuchThing").should.be.nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "SendMessage request type" do
|
|
257
|
+
proto.operation("SendMessage").request_type.should == "SendMessageRequest"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it "SendMessage response type" do
|
|
261
|
+
proto.operation("SendMessage").response_type.should == "SendMessageResponse"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it "GetTask returns Task" do
|
|
265
|
+
proto.operation("GetTask").response_type.should == "Task"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it "CancelTask returns Task" do
|
|
269
|
+
proto.operation("CancelTask").response_type.should == "Task"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it "GetExtendedAgentCard returns AgentCard" do
|
|
273
|
+
proto.operation("GetExtendedAgentCard").response_type.should == "AgentCard"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "DeleteTaskPushNotificationConfig returns google.protobuf.Empty" do
|
|
277
|
+
proto.operation("DeleteTaskPushNotificationConfig")
|
|
278
|
+
.response_type.should == "google.protobuf.Empty"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "CreateTaskPushNotificationConfig request and response are both TaskPushNotificationConfig" do
|
|
282
|
+
op = proto.operation("CreateTaskPushNotificationConfig")
|
|
283
|
+
op.request_type.should == "TaskPushNotificationConfig"
|
|
284
|
+
op.response_type.should == "TaskPushNotificationConfig"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it "SendStreamingMessage is server-streaming" do
|
|
288
|
+
proto.operation("SendStreamingMessage").server_streaming?.should == true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
it "SubscribeToTask is server-streaming" do
|
|
292
|
+
proto.operation("SubscribeToTask").server_streaming?.should == true
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "SendMessage is not server-streaming" do
|
|
296
|
+
proto.operation("SendMessage").server_streaming?.should == false
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "GetTask is not server-streaming" do
|
|
300
|
+
proto.operation("GetTask").server_streaming?.should == false
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
it "SendMessage has POST /message:send" do
|
|
304
|
+
b = proto.operation("SendMessage").http_bindings.first
|
|
305
|
+
b.verb.should == "post"
|
|
306
|
+
b.path.should == "/message:send"
|
|
307
|
+
b.body.should == "*"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "GetTask has GET /tasks/{id=*}" do
|
|
311
|
+
b = proto.operation("GetTask").http_bindings.first
|
|
312
|
+
b.verb.should == "get"
|
|
313
|
+
b.path.should == "/tasks/{id=*}"
|
|
314
|
+
b.body.should.be.nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it "ListTasks has GET /tasks" do
|
|
318
|
+
b = proto.operation("ListTasks").http_bindings.first
|
|
319
|
+
b.verb.should == "get"
|
|
320
|
+
b.path.should == "/tasks"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it "CancelTask has POST /tasks/{id=*}:cancel" do
|
|
324
|
+
b = proto.operation("CancelTask").http_bindings.first
|
|
325
|
+
b.verb.should == "post"
|
|
326
|
+
b.path.should == "/tasks/{id=*}:cancel"
|
|
327
|
+
b.body.should == "*"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it "DeleteTaskPushNotificationConfig has DELETE verb" do
|
|
331
|
+
b = proto.operation("DeleteTaskPushNotificationConfig").http_bindings.first
|
|
332
|
+
b.verb.should == "delete"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "each operation has at least 2 HTTP bindings (primary + tenant)" do
|
|
336
|
+
proto.operations.each do |op|
|
|
337
|
+
op.http_bindings.size.should >= 2
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "second binding is the tenant-prefixed variant" do
|
|
342
|
+
b = proto.operation("SendMessage").http_bindings[1]
|
|
343
|
+
b.path.should == "/{tenant}/message:send"
|
|
344
|
+
b.verb.should == "post"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "GetTask tenant binding" do
|
|
348
|
+
b = proto.operation("GetTask").http_bindings[1]
|
|
349
|
+
b.path.should == "/{tenant}/tasks/{id=*}"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
it "rest_verb returns the primary verb" do
|
|
353
|
+
proto.operation("GetTask").rest_verb.should == "get"
|
|
354
|
+
proto.operation("SendMessage").rest_verb.should == "post"
|
|
355
|
+
proto.operation("DeleteTaskPushNotificationConfig").rest_verb.should == "delete"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it "rest_path returns the primary path" do
|
|
359
|
+
proto.operation("GetTask").rest_path.should == "/tasks/{id=*}"
|
|
360
|
+
proto.operation("ListTasks").rest_path.should == "/tasks"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it "json_rpc_method equals the operation name" do
|
|
364
|
+
proto.operations.each do |op|
|
|
365
|
+
op.json_rpc_method.should == op.name
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "grpc_method equals the operation name" do
|
|
370
|
+
proto.operations.each do |op|
|
|
371
|
+
op.grpc_method.should == op.name
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "request_schema returns the matching Schema class" do
|
|
376
|
+
op = proto.operation("SendMessage")
|
|
377
|
+
op.request_schema.should == A2A::Schema["Send Message Request"]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it "response_schema returns the matching Schema class" do
|
|
381
|
+
op = proto.operation("SendMessage")
|
|
382
|
+
op.response_schema.should == A2A::Schema["Send Message Response"]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it "response_schema for GetTask returns Task schema" do
|
|
386
|
+
op = proto.operation("GetTask")
|
|
387
|
+
op.response_schema.should == A2A::Schema["Task"]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it "response_schema returns nil for google.protobuf.Empty" do
|
|
391
|
+
op = proto.operation("DeleteTaskPushNotificationConfig")
|
|
392
|
+
op.response_schema.should.be.nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
it "every operation has a valid request_schema" do
|
|
396
|
+
proto.operations.each do |op|
|
|
397
|
+
op.request_schema.should.be.kind_of(Class)
|
|
398
|
+
(op.request_schema < A2A::Schema::Definition).should == true
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it "every non-Empty operation has a valid response_schema" do
|
|
403
|
+
proto.operations.each do |op|
|
|
404
|
+
next if op.response_type.include?(".")
|
|
405
|
+
op.response_schema.should.be.kind_of(Class)
|
|
406
|
+
(op.response_schema < A2A::Schema::Definition).should == true
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it "can build and validate a SendMessage request" do
|
|
411
|
+
op = proto.operation("SendMessage")
|
|
412
|
+
req = op.request_schema.new(
|
|
413
|
+
message: {
|
|
414
|
+
"messageId" => "msg-1",
|
|
415
|
+
"role" => "ROLE_USER",
|
|
416
|
+
"parts" => [{ "text" => "Hello" }]
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
req.valid?.should == true
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it "can build and validate a GetTask request" do
|
|
423
|
+
op = proto.operation("GetTask")
|
|
424
|
+
req = op.request_schema.new(id: "task-123")
|
|
425
|
+
req.valid?.should == true
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it "can build and validate a Task response" do
|
|
429
|
+
op = proto.operation("GetTask")
|
|
430
|
+
resp = op.response_schema.new(
|
|
431
|
+
id: "task-123",
|
|
432
|
+
context_id: "ctx-456",
|
|
433
|
+
status: { "state" => "TASK_STATE_SUBMITTED" }
|
|
434
|
+
)
|
|
435
|
+
resp.valid?.should == true
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
it "has a useful inspect" do
|
|
439
|
+
op = proto.operation("SendMessage")
|
|
440
|
+
op.inspect.should.include?("SendMessage")
|
|
441
|
+
op.inspect.should.include?("POST")
|
|
442
|
+
op.inspect.should.include?("/message:send")
|
|
443
|
+
end
|
|
444
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Schema
|
|
8
|
+
# Base class for schema-validated A2A protocol objects.
|
|
9
|
+
#
|
|
10
|
+
# Each A2A definition type (Agent Card, Agent Capabilities, Task, etc.)
|
|
11
|
+
# gets a dynamically-generated subclass of Definition with:
|
|
12
|
+
# - A JSONSchemer sub-schema attached (.schema)
|
|
13
|
+
# - Reader methods for each property (snake_case)
|
|
14
|
+
# - Validation via .valid? / .valid!
|
|
15
|
+
#
|
|
16
|
+
# caps = A2A::Schema["Agent Capabilities"].new(
|
|
17
|
+
# streaming: true,
|
|
18
|
+
# push_notifications: false
|
|
19
|
+
# )
|
|
20
|
+
# caps.valid? #=> true
|
|
21
|
+
# caps.streaming #=> true
|
|
22
|
+
# caps.push_notifications #=> false
|
|
23
|
+
# caps.to_h #=> { "streaming" => true, "pushNotifications" => false }
|
|
24
|
+
#
|
|
25
|
+
class Definition
|
|
26
|
+
|
|
27
|
+
def initialize(hash = {})
|
|
28
|
+
props = self.class.schema_properties
|
|
29
|
+
snake = self.class.snake_to_camel_map
|
|
30
|
+
@data = {}
|
|
31
|
+
|
|
32
|
+
hash.each do |key, value|
|
|
33
|
+
k = key.to_s
|
|
34
|
+
|
|
35
|
+
# Resolve snake_case input to camelCase storage key
|
|
36
|
+
camel = snake[k] || k
|
|
37
|
+
|
|
38
|
+
if props.include?(camel)
|
|
39
|
+
@data[camel] = value.is_a?(Definition) ? value.to_h : value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- class methods overridden by the factory -----------------------
|
|
45
|
+
|
|
46
|
+
def self.schema
|
|
47
|
+
raise "A2A::Schema::Definition should NOT be instantiated directly"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.definition_name
|
|
51
|
+
raise "A2A::Schema::Definition should NOT be instantiated directly"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.schema_properties
|
|
55
|
+
raise "A2A::Schema::Definition should NOT be instantiated directly"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.snake_to_camel_map
|
|
59
|
+
raise "A2A::Schema::Definition should NOT be instantiated directly"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- validation ----------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def valid?
|
|
65
|
+
self.class.schema.valid?(to_h)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def valid!
|
|
69
|
+
errors = self.class.schema.validate(to_h).to_a
|
|
70
|
+
return true if errors.empty?
|
|
71
|
+
|
|
72
|
+
raise ValidationError.new(errors,
|
|
73
|
+
definition_name: self.class.definition_name,
|
|
74
|
+
data: to_h
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# --- serialization -------------------------------------------------
|
|
79
|
+
|
|
80
|
+
# Returns the data as a plain Hash with camelCase string keys,
|
|
81
|
+
# matching the JSON wire format. Nested Definition instances
|
|
82
|
+
# are auto-coerced via deep_compact.
|
|
83
|
+
def to_h
|
|
84
|
+
deep_compact(@data)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ==(other)
|
|
88
|
+
other.is_a?(Definition) && to_h == other.to_h
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inspect
|
|
92
|
+
"#<#{self.class.definition_name} #{to_h.inspect}>"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def deep_compact(obj)
|
|
98
|
+
case obj
|
|
99
|
+
when Hash
|
|
100
|
+
obj.each_with_object({}) do |(k, v), result|
|
|
101
|
+
compacted = deep_compact(v)
|
|
102
|
+
result[k] = compacted unless compacted.nil?
|
|
103
|
+
end
|
|
104
|
+
when Array
|
|
105
|
+
obj.map { |v| deep_compact(v) }
|
|
106
|
+
when Definition
|
|
107
|
+
obj.to_h
|
|
108
|
+
else
|
|
109
|
+
obj
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Schema
|
|
8
|
+
# Raised when a Definition instance fails schema validation.
|
|
9
|
+
#
|
|
10
|
+
# Collects JSONSchemer error details and formats them as a
|
|
11
|
+
# human-readable list with dot-notation field paths.
|
|
12
|
+
#
|
|
13
|
+
# Agent Card validation failed:
|
|
14
|
+
# - name is required but missing
|
|
15
|
+
# - capabilities.streaming must be boolean, got string
|
|
16
|
+
#
|
|
17
|
+
class ValidationError < StandardError
|
|
18
|
+
attr_reader :errors, :definition_name, :data
|
|
19
|
+
|
|
20
|
+
def initialize(errors, definition_name:, data: nil)
|
|
21
|
+
@errors = errors
|
|
22
|
+
@definition_name = definition_name
|
|
23
|
+
@data = data
|
|
24
|
+
|
|
25
|
+
super(build_message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_message
|
|
31
|
+
lines = errors.map { |e| " - #{format_error(e)}" }
|
|
32
|
+
"#{definition_name} validation failed:\n#{lines.join("\n")}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def format_error(error)
|
|
36
|
+
path = format_path(error)
|
|
37
|
+
type = error["type"]
|
|
38
|
+
|
|
39
|
+
case type
|
|
40
|
+
when "required"
|
|
41
|
+
missing = error.dig("details", "missing_keys")&.join(", ") || "unknown"
|
|
42
|
+
if path.empty?
|
|
43
|
+
"#{missing} is required but missing"
|
|
44
|
+
else
|
|
45
|
+
"#{path}.#{missing} is required but missing"
|
|
46
|
+
end
|
|
47
|
+
when "type"
|
|
48
|
+
expected = Array(error.dig("schema", "type")).join(" or ")
|
|
49
|
+
"#{path.empty? ? "(root)" : path} must be #{expected}"
|
|
50
|
+
when "enum"
|
|
51
|
+
allowed = error.dig("schema", "enum")&.join(", ") || "?"
|
|
52
|
+
"#{path.empty? ? "(root)" : path} must be one of: #{allowed}"
|
|
53
|
+
when "pattern"
|
|
54
|
+
pattern = error.dig("schema", "pattern")
|
|
55
|
+
"#{path.empty? ? "(root)" : path} must match pattern #{pattern}"
|
|
56
|
+
when "format"
|
|
57
|
+
fmt = error.dig("schema", "format")
|
|
58
|
+
"#{path.empty? ? "(root)" : path} must be a valid #{fmt}"
|
|
59
|
+
when "minimum", "maximum"
|
|
60
|
+
"#{path.empty? ? "(root)" : path} #{error["error"]}"
|
|
61
|
+
when "additionalProperties"
|
|
62
|
+
"#{path.empty? ? "(root)" : path} has unknown properties"
|
|
63
|
+
else
|
|
64
|
+
detail = error["error"] || error["type"] || "invalid"
|
|
65
|
+
"#{path.empty? ? "(root)" : path} #{detail}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Convert JSON pointer like "/properties/capabilities/streaming"
|
|
70
|
+
# to dot notation like "capabilities.streaming"
|
|
71
|
+
def format_path(error)
|
|
72
|
+
pointer = error["data_pointer"].to_s
|
|
73
|
+
return "" if pointer.empty? || pointer == "/"
|
|
74
|
+
|
|
75
|
+
pointer.delete_prefix("/").gsub("/", ".")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
test do
|
|
82
|
+
error_data = [
|
|
83
|
+
{
|
|
84
|
+
"data_pointer" => "",
|
|
85
|
+
"type" => "required",
|
|
86
|
+
"details" => { "missing_keys" => ["name"] },
|
|
87
|
+
"schema" => {},
|
|
88
|
+
"error" => "missing keys: name"
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
err = A2A::Schema::ValidationError.new(error_data, definition_name: "Agent Card")
|
|
93
|
+
|
|
94
|
+
it "includes the definition name" do
|
|
95
|
+
err.message.should.include?("Agent Card")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "formats required errors" do
|
|
99
|
+
err.message.should.include?("name is required but missing")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "stores the errors array" do
|
|
103
|
+
err.errors.should == error_data
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "stores the definition name" do
|
|
107
|
+
err.definition_name.should == "Agent Card"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
nested = A2A::Schema::ValidationError.new(
|
|
111
|
+
[{ "data_pointer" => "/capabilities/streaming", "type" => "type",
|
|
112
|
+
"schema" => { "type" => "boolean" }, "error" => "wrong type" }],
|
|
113
|
+
definition_name: "Agent Card"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
it "formats nested paths with dot notation" do
|
|
117
|
+
nested.message.should.include?("capabilities.streaming must be boolean")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
additional = A2A::Schema::ValidationError.new(
|
|
121
|
+
[{ "data_pointer" => "/foo", "type" => "additionalProperties",
|
|
122
|
+
"schema" => {}, "error" => "unexpected" }],
|
|
123
|
+
definition_name: "Test"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
it "formats additionalProperties errors" do
|
|
127
|
+
additional.message.should.include?("foo has unknown properties")
|
|
128
|
+
end
|
|
129
|
+
end
|