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/schema.rb
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
# Schema-validated A2A protocol objects, powered by json_schemer.
|
|
8
|
+
#
|
|
9
|
+
# Loads the bundled data/a2a.json schema, rewrites external $ref
|
|
10
|
+
# values to internal #/definitions/... pointers, and dynamically
|
|
11
|
+
# generates Definition subclasses for each type.
|
|
12
|
+
#
|
|
13
|
+
# A2A::Schema["Agent Capabilities"]
|
|
14
|
+
# #=> Class < Definition with .schema, .valid?, reader methods
|
|
15
|
+
#
|
|
16
|
+
# A2A::Schema["Agent Card"]
|
|
17
|
+
# #=> Class < Definition
|
|
18
|
+
#
|
|
19
|
+
# A2A::Schema.list_definitions
|
|
20
|
+
# #=> ["API Key Security Scheme", "Agent Capabilities", ...]
|
|
21
|
+
#
|
|
22
|
+
module Schema
|
|
23
|
+
DATA_PATH = File.expand_path("../../data/a2a.json", __dir__).freeze
|
|
24
|
+
|
|
25
|
+
@definition_classes = {}
|
|
26
|
+
@schemer = nil
|
|
27
|
+
@raw_schema = nil
|
|
28
|
+
@ref_map = nil
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Look up a definition by title.
|
|
32
|
+
#
|
|
33
|
+
# A2A::Schema["Agent Capabilities"]
|
|
34
|
+
# #=> Class < Definition
|
|
35
|
+
#
|
|
36
|
+
def [](name)
|
|
37
|
+
@definition_classes[name] ||= begin
|
|
38
|
+
definitions = raw_schema.fetch("definitions", {})
|
|
39
|
+
|
|
40
|
+
unless definitions.key?(name)
|
|
41
|
+
raise "No A2A definition found for #{name.inspect}!" \
|
|
42
|
+
"\nAvailable: #{list_definitions.join(", ")}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
encoded = URI::DEFAULT_PARSER.escape(name)
|
|
46
|
+
ref_schema = schemer.ref("#/definitions/#{encoded}")
|
|
47
|
+
build_definition_class(ref_schema, name, definitions[name])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# All available definition titles, sorted.
|
|
52
|
+
#
|
|
53
|
+
# A2A::Schema.list_definitions
|
|
54
|
+
# #=> ["API Key Security Scheme", "Agent Capabilities", ...]
|
|
55
|
+
#
|
|
56
|
+
def list_definitions
|
|
57
|
+
raw_schema.fetch("definitions", {}).keys.sort
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The JSONSchemer instance for the full A2A schema bundle.
|
|
61
|
+
# Cached after first access.
|
|
62
|
+
def schemer
|
|
63
|
+
@schemer ||= JSONSchemer.schema(raw_schema)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The parsed + ref-rewritten JSON schema hash.
|
|
67
|
+
def raw_schema
|
|
68
|
+
@raw_schema ||= load_and_rewrite_schema
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Reset all cached state (useful for tests).
|
|
72
|
+
def reset!
|
|
73
|
+
@definition_classes.clear
|
|
74
|
+
@schemer = nil
|
|
75
|
+
@raw_schema = nil
|
|
76
|
+
@ref_map = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Load data/a2a.json, build the $ref rewrite map, and
|
|
82
|
+
# walk the entire tree replacing external refs with internal ones.
|
|
83
|
+
def load_and_rewrite_schema
|
|
84
|
+
schema = JSON.parse(File.read(DATA_PATH))
|
|
85
|
+
map = build_ref_map(schema)
|
|
86
|
+
rewrite_refs!(schema, map)
|
|
87
|
+
schema
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build a map from external $ref strings to internal
|
|
91
|
+
# #/definitions/Title pointers.
|
|
92
|
+
def build_ref_map(schema)
|
|
93
|
+
return @ref_map if @ref_map
|
|
94
|
+
|
|
95
|
+
definitions = schema.fetch("definitions", {})
|
|
96
|
+
|
|
97
|
+
pascal_to_title = {}
|
|
98
|
+
definitions.each_key do |title|
|
|
99
|
+
pascal = title.gsub(/\s+/, "")
|
|
100
|
+
pascal_to_title[pascal] = title
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
refs = collect_refs(schema)
|
|
104
|
+
|
|
105
|
+
map = {}
|
|
106
|
+
refs.each do |ref_str|
|
|
107
|
+
type_name = ref_str
|
|
108
|
+
.sub(/\.jsonschema\.json\z/, "")
|
|
109
|
+
.split(".")
|
|
110
|
+
.last
|
|
111
|
+
|
|
112
|
+
if (title = pascal_to_title[type_name])
|
|
113
|
+
encoded = URI::DEFAULT_PARSER.escape(title)
|
|
114
|
+
map[ref_str] = "#/definitions/#{encoded}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@ref_map = map
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Recursively collect all $ref string values from a JSON tree.
|
|
122
|
+
def collect_refs(obj, refs = Set.new)
|
|
123
|
+
case obj
|
|
124
|
+
when Hash
|
|
125
|
+
obj.each do |k, v|
|
|
126
|
+
if k == "$ref" && v.is_a?(String) && !v.start_with?("#")
|
|
127
|
+
refs << v
|
|
128
|
+
else
|
|
129
|
+
collect_refs(v, refs)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
when Array
|
|
133
|
+
obj.each { |v| collect_refs(v, refs) }
|
|
134
|
+
end
|
|
135
|
+
refs
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Walk the schema tree and replace all external $ref values
|
|
139
|
+
# with their internal #/definitions/... equivalents.
|
|
140
|
+
def rewrite_refs!(obj, map)
|
|
141
|
+
case obj
|
|
142
|
+
when Hash
|
|
143
|
+
obj.each do |k, v|
|
|
144
|
+
if k == "$ref" && v.is_a?(String) && map.key?(v)
|
|
145
|
+
obj[k] = map[v]
|
|
146
|
+
else
|
|
147
|
+
rewrite_refs!(v, map)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
when Array
|
|
151
|
+
obj.each { |v| rewrite_refs!(v, map) }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Build a Definition subclass for a specific A2A type.
|
|
156
|
+
def build_definition_class(schema_instance, definition_name, raw_definition)
|
|
157
|
+
properties = raw_definition.fetch("properties", {})
|
|
158
|
+
camel_keys = properties.keys
|
|
159
|
+
snake_to_camel = build_snake_to_camel(camel_keys)
|
|
160
|
+
|
|
161
|
+
reader_pairs = camel_keys.map { |ck| [camel_to_snake(ck).to_sym, ck] }
|
|
162
|
+
|
|
163
|
+
Class.new(Definition) do
|
|
164
|
+
@schema = schema_instance
|
|
165
|
+
@definition_name = definition_name
|
|
166
|
+
@schema_properties = camel_keys
|
|
167
|
+
@snake_to_camel = snake_to_camel
|
|
168
|
+
|
|
169
|
+
class << self
|
|
170
|
+
def schema = @schema
|
|
171
|
+
def definition_name = @definition_name
|
|
172
|
+
def schema_properties = @schema_properties
|
|
173
|
+
def snake_to_camel_map = @snake_to_camel
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
reader_pairs.each do |snake_sym, camel_key|
|
|
177
|
+
define_method(snake_sym) { @data[camel_key] }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_snake_to_camel(camel_keys)
|
|
183
|
+
map = {}
|
|
184
|
+
camel_keys.each do |camel|
|
|
185
|
+
snake = camel_to_snake(camel)
|
|
186
|
+
map[snake] = camel
|
|
187
|
+
map[camel] = camel
|
|
188
|
+
end
|
|
189
|
+
map
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def camel_to_snake(str)
|
|
193
|
+
str.gsub(/([A-Z])/) { "_#{$1.downcase}" }
|
|
194
|
+
.delete_prefix("_")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
test do
|
|
201
|
+
schema = A2A::Schema
|
|
202
|
+
|
|
203
|
+
it "loads the raw schema" do
|
|
204
|
+
schema.raw_schema.should.be.kind_of(Hash)
|
|
205
|
+
schema.raw_schema["definitions"].should.be.kind_of(Hash)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "has a schemer instance" do
|
|
209
|
+
schema.schemer.should.be.kind_of(JSONSchemer::Schema)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "rewrites $ref values to internal pointers" do
|
|
213
|
+
defs = schema.raw_schema["definitions"]
|
|
214
|
+
external_refs = []
|
|
215
|
+
walk = ->(obj) do
|
|
216
|
+
case obj
|
|
217
|
+
when Hash
|
|
218
|
+
obj.each do |k, v|
|
|
219
|
+
if k == "$ref" && v.is_a?(String) && !v.start_with?("#")
|
|
220
|
+
external_refs << v
|
|
221
|
+
else
|
|
222
|
+
walk.(v)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
when Array
|
|
226
|
+
obj.each { |v| walk.(v) }
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
walk.(defs)
|
|
230
|
+
external_refs.should == []
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "lists all definitions sorted" do
|
|
234
|
+
defs = schema.list_definitions
|
|
235
|
+
defs.should.be.kind_of(Array)
|
|
236
|
+
defs.should.include?("Agent Capabilities")
|
|
237
|
+
defs.should.include?("Agent Card")
|
|
238
|
+
defs.should.include?("Task")
|
|
239
|
+
defs.should.include?("Message")
|
|
240
|
+
defs.should == defs.sort
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "returns a Class that subclasses Definition" do
|
|
244
|
+
klass = schema["Agent Capabilities"]
|
|
245
|
+
klass.should.be.kind_of(Class)
|
|
246
|
+
(klass < A2A::Schema::Definition).should == true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "caches definition classes" do
|
|
250
|
+
a = schema["Agent Capabilities"]
|
|
251
|
+
b = schema["Agent Capabilities"]
|
|
252
|
+
a.object_id.should == b.object_id
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it "raises for unknown definitions" do
|
|
256
|
+
lambda { schema["ThisDoesNotExist999"] }.should.raise(RuntimeError)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "has schema_properties listing camelCase keys" do
|
|
260
|
+
klass = schema["Agent Capabilities"]
|
|
261
|
+
klass.schema_properties.should.include?("extendedAgentCard")
|
|
262
|
+
klass.schema_properties.should.include?("streaming")
|
|
263
|
+
klass.schema_properties.should.include?("pushNotifications")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "has a definition_name" do
|
|
267
|
+
schema["Agent Capabilities"].definition_name.should == "Agent Capabilities"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "creates instances from snake_case keys" do
|
|
271
|
+
caps = schema["Agent Capabilities"].new(
|
|
272
|
+
streaming: true,
|
|
273
|
+
push_notifications: false,
|
|
274
|
+
extended_agent_card: true
|
|
275
|
+
)
|
|
276
|
+
caps.streaming.should == true
|
|
277
|
+
caps.push_notifications.should == false
|
|
278
|
+
caps.extended_agent_card.should == true
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "creates instances from camelCase keys" do
|
|
282
|
+
caps = schema["Agent Capabilities"].new(
|
|
283
|
+
"streaming" => true,
|
|
284
|
+
"pushNotifications" => false
|
|
285
|
+
)
|
|
286
|
+
caps.streaming.should == true
|
|
287
|
+
caps.push_notifications.should == false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "creates instances from symbol camelCase keys" do
|
|
291
|
+
caps = schema["Agent Capabilities"].new(
|
|
292
|
+
streaming: true,
|
|
293
|
+
pushNotifications: false
|
|
294
|
+
)
|
|
295
|
+
caps.streaming.should == true
|
|
296
|
+
caps.push_notifications.should == false
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "ignores unknown properties" do
|
|
300
|
+
caps = schema["Agent Capabilities"].new(bogus: "ignored", streaming: true)
|
|
301
|
+
caps.streaming.should == true
|
|
302
|
+
caps.to_h.keys.should.not.include?("bogus")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "returns camelCase string keys in to_h" do
|
|
306
|
+
caps = schema["Agent Capabilities"].new(streaming: true, push_notifications: false)
|
|
307
|
+
h = caps.to_h
|
|
308
|
+
h.should.be.kind_of(Hash)
|
|
309
|
+
h["streaming"].should == true
|
|
310
|
+
h["pushNotifications"].should == false
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "validates correct data" do
|
|
314
|
+
caps = schema["Agent Capabilities"].new(streaming: true)
|
|
315
|
+
caps.valid?.should == true
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it "valid! returns true for correct data" do
|
|
319
|
+
caps = schema["Agent Capabilities"].new(streaming: true)
|
|
320
|
+
caps.valid!.should == true
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it "detects type violations" do
|
|
324
|
+
caps = schema["Agent Capabilities"].new(streaming: "not_a_bool")
|
|
325
|
+
caps.valid?.should == false
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "valid! raises ValidationError for invalid data" do
|
|
329
|
+
caps = schema["Agent Capabilities"].new(streaming: "not_a_bool")
|
|
330
|
+
lambda { caps.valid! }.should.raise(A2A::Schema::ValidationError)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it "detects additionalProperties violations" do
|
|
334
|
+
caps = schema["Agent Capabilities"].new(streaming: true)
|
|
335
|
+
caps.valid?.should == true
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "auto-coerces nested Definition instances in constructor" do
|
|
339
|
+
caps = schema["Agent Capabilities"].new(streaming: true)
|
|
340
|
+
card = schema["Agent Card"].new(
|
|
341
|
+
name: "Test Agent",
|
|
342
|
+
version: "1.0.0",
|
|
343
|
+
capabilities: caps
|
|
344
|
+
)
|
|
345
|
+
card.name.should == "Test Agent"
|
|
346
|
+
card.version.should == "1.0.0"
|
|
347
|
+
h = card.to_h
|
|
348
|
+
h["capabilities"].should.be.kind_of(Hash)
|
|
349
|
+
h["capabilities"]["streaming"].should == true
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
it "validates Agent Card with nested capabilities" do
|
|
353
|
+
card = schema["Agent Card"].new(
|
|
354
|
+
name: "Test Agent",
|
|
355
|
+
version: "1.0.0",
|
|
356
|
+
capabilities: schema["Agent Capabilities"].new(streaming: true)
|
|
357
|
+
)
|
|
358
|
+
card.valid?.should == true
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
it "validates Agent Card with skills array" do
|
|
362
|
+
card = schema["Agent Card"].new(
|
|
363
|
+
name: "Test Agent",
|
|
364
|
+
version: "1.0.0",
|
|
365
|
+
skills: [
|
|
366
|
+
{ "id" => "search", "name" => "Web Search", "description" => "Searches the web" }
|
|
367
|
+
]
|
|
368
|
+
)
|
|
369
|
+
card.valid?.should == true
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "has a useful inspect" do
|
|
373
|
+
caps = schema["Agent Capabilities"].new(streaming: true)
|
|
374
|
+
caps.inspect.should.include?("Agent Capabilities")
|
|
375
|
+
caps.inspect.should.include?("streaming")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
it "considers two definitions equal when data matches" do
|
|
379
|
+
a = schema["Agent Capabilities"].new(streaming: true)
|
|
380
|
+
b = schema["Agent Capabilities"].new(streaming: true)
|
|
381
|
+
(a == b).should == true
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "considers two definitions unequal when data differs" do
|
|
385
|
+
a = schema["Agent Capabilities"].new(streaming: true)
|
|
386
|
+
b = schema["Agent Capabilities"].new(streaming: false)
|
|
387
|
+
(a == b).should == false
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it "validates Task with nested TaskStatus" do
|
|
391
|
+
task = schema["Task"].new(
|
|
392
|
+
id: "task-123",
|
|
393
|
+
context_id: "ctx-456",
|
|
394
|
+
status: {
|
|
395
|
+
"state" => "TASK_STATE_SUBMITTED",
|
|
396
|
+
"timestamp" => "2025-01-01T00:00:00Z"
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
task.valid?.should == true
|
|
400
|
+
task.id.should == "task-123"
|
|
401
|
+
task.context_id.should == "ctx-456"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
it "validates a Message with parts" do
|
|
405
|
+
msg = schema["Message"].new(
|
|
406
|
+
role: "ROLE_USER",
|
|
407
|
+
message_id: "msg-1",
|
|
408
|
+
parts: [{ "text" => "Hello" }]
|
|
409
|
+
)
|
|
410
|
+
msg.valid?.should == true
|
|
411
|
+
msg.role.should == "ROLE_USER"
|
|
412
|
+
msg.message_id.should == "msg-1"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
it "validates a Part" do
|
|
416
|
+
part = schema["Part"].new(text: "Hello world", media_type: "text/plain")
|
|
417
|
+
part.valid?.should == true
|
|
418
|
+
part.text.should == "Hello world"
|
|
419
|
+
part.media_type.should == "text/plain"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it "can instantiate every definition without error" do
|
|
423
|
+
schema.list_definitions.each do |name|
|
|
424
|
+
klass = schema[name]
|
|
425
|
+
instance = klass.new
|
|
426
|
+
instance.is_a?(A2A::Schema::Definition).should == true
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Server
|
|
8
|
+
class CreateTaskPushNotificationConfig
|
|
9
|
+
def call(env)
|
|
10
|
+
env["a2a.result"] = Schema["Task Push Notification Config"].new({})
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "console"
|
|
5
|
+
require "a2a"
|
|
6
|
+
|
|
7
|
+
module A2A
|
|
8
|
+
class Server
|
|
9
|
+
# Routes incoming A2A operations to registered handler objects.
|
|
10
|
+
#
|
|
11
|
+
# Each handler declares the operations it handles via `#operations`.
|
|
12
|
+
# When an operation arrives, the dispatcher finds all matching handlers
|
|
13
|
+
# and calls them. Errors in one handler do not prevent others from running.
|
|
14
|
+
#
|
|
15
|
+
# The Dispatcher is a Rack app (terminal, not middleware). It reads
|
|
16
|
+
# env["a2a.operation"] set by Triage and fans out to matching handlers.
|
|
17
|
+
#
|
|
18
|
+
class Dispatcher
|
|
19
|
+
def initialize
|
|
20
|
+
@handlers = Hash.new { |h, k| h[k] = [] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Register a handler object.
|
|
24
|
+
#
|
|
25
|
+
# The handler must respond to:
|
|
26
|
+
# #operations -> Array<String> (e.g. ["SendMessage", "GetTask"])
|
|
27
|
+
# #call(env) -> void (sets env["a2a.result"])
|
|
28
|
+
#
|
|
29
|
+
def register(handler)
|
|
30
|
+
handler.operations.each do |op|
|
|
31
|
+
@handlers[op] << handler
|
|
32
|
+
Console.info(self) { "Registered #{handler.class.name} for #{op}" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(env)
|
|
37
|
+
operation = env["a2a.operation"]
|
|
38
|
+
|
|
39
|
+
if operation
|
|
40
|
+
dispatch(operation, env)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[200, {}, []]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handler_count
|
|
47
|
+
@handlers.values.flatten.size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def dispatch(operation, env)
|
|
53
|
+
handlers = @handlers[operation]
|
|
54
|
+
|
|
55
|
+
if handlers.empty?
|
|
56
|
+
Console.debug(self) { "No handler for operation: #{operation}" }
|
|
57
|
+
else
|
|
58
|
+
handlers.each do |handler|
|
|
59
|
+
begin
|
|
60
|
+
handler.call(env)
|
|
61
|
+
rescue => e
|
|
62
|
+
Console.error(self) { "Handler #{handler.class.name} raised #{e.class}: #{e.message}" }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test do
|
|
72
|
+
describe "A2A::Server::Dispatcher" do
|
|
73
|
+
it "registers and dispatches to handlers" do
|
|
74
|
+
received = []
|
|
75
|
+
handler = Object.new
|
|
76
|
+
handler.define_singleton_method(:operations) { ["SendMessage"] }
|
|
77
|
+
handler.define_singleton_method(:call) { |env| received << env }
|
|
78
|
+
|
|
79
|
+
dispatcher = A2A::Server::Dispatcher.new
|
|
80
|
+
dispatcher.register(handler)
|
|
81
|
+
dispatcher.handler_count.should == 1
|
|
82
|
+
|
|
83
|
+
env = { "a2a.operation" => "SendMessage" }
|
|
84
|
+
dispatcher.call(env)
|
|
85
|
+
received.length.should == 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "ignores operations with no matching handler" do
|
|
89
|
+
dispatcher = A2A::Server::Dispatcher.new
|
|
90
|
+
env = { "a2a.operation" => "UnknownOp" }
|
|
91
|
+
lambda { dispatcher.call(env) }.should.not.raise
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "continues dispatching when a handler raises" do
|
|
95
|
+
results = []
|
|
96
|
+
bad_handler = Object.new
|
|
97
|
+
bad_handler.define_singleton_method(:operations) { ["SendMessage"] }
|
|
98
|
+
bad_handler.define_singleton_method(:call) { |_| raise "boom" }
|
|
99
|
+
|
|
100
|
+
good_handler = Object.new
|
|
101
|
+
good_handler.define_singleton_method(:operations) { ["SendMessage"] }
|
|
102
|
+
good_handler.define_singleton_method(:call) { |e| results << e }
|
|
103
|
+
|
|
104
|
+
dispatcher = A2A::Server::Dispatcher.new
|
|
105
|
+
dispatcher.register(bad_handler)
|
|
106
|
+
dispatcher.register(good_handler)
|
|
107
|
+
|
|
108
|
+
env = { "a2a.operation" => "SendMessage" }
|
|
109
|
+
dispatcher.call(env)
|
|
110
|
+
results.length.should == 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "dispatches to multiple operations from one handler" do
|
|
114
|
+
received = []
|
|
115
|
+
handler = Object.new
|
|
116
|
+
handler.define_singleton_method(:operations) { ["SendMessage", "GetTask"] }
|
|
117
|
+
handler.define_singleton_method(:call) { |env| received << env["a2a.operation"] }
|
|
118
|
+
|
|
119
|
+
dispatcher = A2A::Server::Dispatcher.new
|
|
120
|
+
dispatcher.register(handler)
|
|
121
|
+
|
|
122
|
+
dispatcher.call({ "a2a.operation" => "SendMessage" })
|
|
123
|
+
dispatcher.call({ "a2a.operation" => "GetTask" })
|
|
124
|
+
received.should == ["SendMessage", "GetTask"]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
class Server
|
|
8
|
+
# Rack middleware that injects shared A2A context into the env.
|
|
9
|
+
#
|
|
10
|
+
# Sets env["a2a.store"] and env["a2a.agent_card"] so downstream
|
|
11
|
+
# middleware and handlers can access them without coupling to
|
|
12
|
+
# any particular configuration mechanism.
|
|
13
|
+
#
|
|
14
|
+
class Env
|
|
15
|
+
def initialize(app, agent_card: {}, store: TaskStore.new)
|
|
16
|
+
@app = app
|
|
17
|
+
@agent_card = agent_card
|
|
18
|
+
@store = store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
env["a2a.store"] = @store
|
|
23
|
+
env["a2a.agent_card"] = @agent_card
|
|
24
|
+
@app.call(env)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Server
|
|
8
|
+
class GetExtendedAgentCard
|
|
9
|
+
def call(env)
|
|
10
|
+
card = env["a2a.agent_card"] || {}
|
|
11
|
+
env["a2a.result"] = Schema["Agent Card"].new(card)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Server
|
|
8
|
+
class GetTaskPushNotificationConfig
|
|
9
|
+
def call(env)
|
|
10
|
+
env["a2a.result"] = Schema["Task Push Notification Config"].new({})
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Server
|
|
8
|
+
class ListTaskPushNotificationConfigs
|
|
9
|
+
def call(env)
|
|
10
|
+
env["a2a.result"] = Schema["List Task Push Notification Configs Response"].new({})
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|