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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/data/a2a.json +1961 -0
  3. data/data/a2a.proto +796 -0
  4. data/data/download-a2a-resources +4 -0
  5. data/data/spec.txt +3611 -0
  6. data/lib/a2a/agent.rb +243 -0
  7. data/lib/a2a/bindings/json_rpc.rb +110 -0
  8. data/lib/a2a/bindings/rest.rb +106 -0
  9. data/lib/a2a/client.rb +85 -0
  10. data/lib/a2a/proto.rb +444 -0
  11. data/lib/a2a/schema/definition.rb +114 -0
  12. data/lib/a2a/schema/validation_error.rb +129 -0
  13. data/lib/a2a/schema.rb +429 -0
  14. data/lib/a2a/server/cancel_task.rb +14 -0
  15. data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
  16. data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
  17. data/lib/a2a/server/dispatcher.rb +127 -0
  18. data/lib/a2a/server/env.rb +28 -0
  19. data/lib/a2a/server/get_extended_agent_card.rb +15 -0
  20. data/lib/a2a/server/get_task.rb +14 -0
  21. data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
  22. data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
  23. data/lib/a2a/server/list_tasks.rb +14 -0
  24. data/lib/a2a/server/send_message.rb +14 -0
  25. data/lib/a2a/server/send_streaming_message.rb +14 -0
  26. data/lib/a2a/server/subscribe_to_task.rb +14 -0
  27. data/lib/a2a/server/triage.rb +96 -0
  28. data/lib/a2a/server.rb +98 -0
  29. data/lib/a2a/sse/json_rpc_stream.rb +66 -0
  30. data/lib/a2a/sse/rest_stream.rb +50 -0
  31. data/lib/a2a/sse/stream.rb +129 -0
  32. data/lib/a2a/sse.rb +5 -0
  33. data/lib/a2a/store/processor.rb +136 -0
  34. data/lib/a2a/store/pub_sub.rb +149 -0
  35. data/lib/a2a/store/sqlite.rb +533 -0
  36. data/lib/a2a/store/webhooks.rb +94 -0
  37. data/lib/a2a/store.rb +6 -0
  38. data/lib/a2a/task_store.rb +315 -0
  39. data/lib/a2a/version.rb +5 -0
  40. data/lib/a2a.rb +26 -0
  41. metadata +216 -0
data/lib/a2a/agent.rb ADDED
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ # DSL wrapper that collects operation handlers for an A2A agent.
8
+ #
9
+ # An Agent produces handler objects that conform to the Dispatcher's
10
+ # duck-type contract (#operations, #call). Register an agent on a
11
+ # Server the same way you would register a plain handler.
12
+ #
13
+ # agent = A2A::Agent.new do
14
+ # on "SendMessage" do |request|
15
+ # respond A2A::Schema["Send Message Response"].new({})
16
+ # end
17
+ #
18
+ # on "GetTask" do |request|
19
+ # task = store.get(request.id)
20
+ # respond A2A::Schema["Task"].new(task.to_h)
21
+ # end
22
+ # end
23
+ #
24
+ # server.register(agent)
25
+ #
26
+ class Agent
27
+ attr_reader :handlers
28
+
29
+ def initialize(&block)
30
+ @handlers = []
31
+
32
+ instance_eval(&block) if block
33
+ end
34
+
35
+ # Register a handler block for one or more A2A operations.
36
+ #
37
+ # Operations are identified by their proto name (e.g. "SendMessage",
38
+ # "GetTask", "CancelTask"). See A2A::Proto.operations for the full list.
39
+ #
40
+ def on(*operations, &block)
41
+ raise ArgumentError, "on requires at least one operation" if operations.empty?
42
+ raise ArgumentError, "on requires a block" unless block
43
+
44
+ handler = Handler.new(
45
+ agent: self,
46
+ operations: operations.flatten,
47
+ block: block
48
+ )
49
+
50
+ @handlers << handler
51
+ handler
52
+ end
53
+
54
+ # Internal handler object produced by the #on DSL method.
55
+ # Conforms to the Dispatcher duck-type: #operations, #call.
56
+ class Handler
57
+ attr_reader :operations
58
+
59
+ def initialize(agent:, operations:, block:)
60
+ @agent = agent
61
+ @operations = operations
62
+ @block = block
63
+ end
64
+
65
+ def call(env)
66
+ Context.new(env).execute(&@block)
67
+ end
68
+ end
69
+
70
+ # Execution context for handler blocks.
71
+ # Provides helper methods so blocks can call store, respond, stream, etc.
72
+ # directly without holding a reference to the env hash.
73
+ class Context
74
+ def initialize(env)
75
+ @env = env
76
+ end
77
+
78
+ def execute(&block)
79
+ instance_exec(@env["a2a.request"], &block)
80
+ end
81
+
82
+ def store
83
+ @env["a2a.store"]
84
+ end
85
+
86
+ def request
87
+ @env["a2a.request"]
88
+ end
89
+
90
+ def agent_card
91
+ @env["a2a.agent_card"]
92
+ end
93
+
94
+ def respond(result)
95
+ @env["a2a.result"] = result
96
+ end
97
+
98
+ # Create an SSE stream for streaming responses.
99
+ #
100
+ # Automatically selects the right stream type based on the binding:
101
+ # - JSON-RPC binding -> JsonRpcStream (wraps events in envelopes)
102
+ # - REST binding -> RestStream (bare JSON events)
103
+ #
104
+ # The stream is registered on env["a2a.stream"] so the binding
105
+ # middleware returns it as the Rack body. Falcon streams it natively
106
+ # via Protocol::HTTP::Body::Writable — no threads, no polling.
107
+ #
108
+ # Usage in a handler block:
109
+ #
110
+ # on "SendStreamingMessage" do |request|
111
+ # s = stream
112
+ # Async do
113
+ # s.event({ "task" => { ... } })
114
+ # s.event({ "statusUpdate" => { ... } })
115
+ # s.finish
116
+ # end
117
+ # end
118
+ #
119
+ def stream
120
+ require "a2a/sse"
121
+
122
+ s = if @env["a2a.json_rpc_id"]
123
+ A2A::SSE::JsonRpcStream.new(json_rpc_id: @env["a2a.json_rpc_id"])
124
+ else
125
+ A2A::SSE::RestStream.new
126
+ end
127
+
128
+ @env["a2a.stream"] = s
129
+ s
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ test do
136
+ describe "A2A::Agent" do
137
+ it "registers handlers via the on DSL" do
138
+ agent = A2A::Agent.new do
139
+ on "SendMessage" do |request|
140
+ # no-op
141
+ end
142
+ end
143
+
144
+ agent.handlers.length.should == 1
145
+ agent.handlers.first.operations.should == ["SendMessage"]
146
+ end
147
+
148
+ it "registers multiple operations on a single handler" do
149
+ agent = A2A::Agent.new do
150
+ on "SendMessage", "GetTask" do |request|
151
+ # no-op
152
+ end
153
+ end
154
+
155
+ agent.handlers.first.operations.should == ["SendMessage", "GetTask"]
156
+ end
157
+
158
+ it "raises if on is called without operations" do
159
+ lambda {
160
+ A2A::Agent.new do
161
+ on do |request|; end
162
+ end
163
+ }.should.raise(ArgumentError)
164
+ end
165
+
166
+ it "raises if on is called without a block" do
167
+ lambda {
168
+ agent = A2A::Agent.new
169
+ agent.on "SendMessage"
170
+ }.should.raise(ArgumentError)
171
+ end
172
+
173
+ it "executes handler block in Context with access to env" do
174
+ agent = A2A::Agent.new do
175
+ on "SendMessage" do |request|
176
+ respond({ "echo" => true })
177
+ end
178
+ end
179
+
180
+ env = {
181
+ "a2a.store" => A2A::TaskStore.new,
182
+ "a2a.request" => { "message" => "hello" },
183
+ "a2a.agent_card" => { "name" => "Test" },
184
+ }
185
+
186
+ agent.handlers.first.call(env)
187
+
188
+ env["a2a.result"].should == { "echo" => true }
189
+ end
190
+
191
+ it "provides store access in handler context" do
192
+ store = A2A::TaskStore.new
193
+ seen_store = nil
194
+
195
+ agent = A2A::Agent.new do
196
+ on "SendMessage" do |request|
197
+ seen_store = store
198
+ end
199
+ end
200
+
201
+ env = { "a2a.store" => store, "a2a.request" => {} }
202
+ agent.handlers.first.call(env)
203
+
204
+ seen_store.should == store
205
+ end
206
+
207
+ it "creates a JsonRpcStream when JSON-RPC binding is active" do
208
+ agent = A2A::Agent.new do
209
+ on "SendStreamingMessage" do |request|
210
+ s = stream
211
+ s.is_a?(A2A::SSE::JsonRpcStream).should == true
212
+ end
213
+ end
214
+
215
+ env = {
216
+ "a2a.store" => A2A::TaskStore.new,
217
+ "a2a.request" => {},
218
+ "a2a.json_rpc_id" => 42,
219
+ }
220
+ agent.handlers.first.call(env)
221
+
222
+ env["a2a.stream"].should.not.be.nil
223
+ env["a2a.stream"].is_a?(Protocol::HTTP::Body::Readable).should == true
224
+ end
225
+
226
+ it "creates a RestStream when REST binding is active" do
227
+ agent = A2A::Agent.new do
228
+ on "SendStreamingMessage" do |request|
229
+ s = stream
230
+ s.is_a?(A2A::SSE::RestStream).should == true
231
+ end
232
+ end
233
+
234
+ env = {
235
+ "a2a.store" => A2A::TaskStore.new,
236
+ "a2a.request" => {},
237
+ }
238
+ agent.handlers.first.call(env)
239
+
240
+ env["a2a.stream"].should.not.be.nil
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "a2a/sse"
6
+
7
+ module A2A
8
+ module Bindings
9
+ # Rack middleware implementing the A2A JSON-RPC 2.0 protocol binding.
10
+ #
11
+ # Strips the JSON-RPC envelope from the inbound request, setting
12
+ # env keys for the method name, request id, and parsed params.
13
+ # Calls downstream. On return, wraps env["a2a.result"] back into
14
+ # a JSON-RPC response envelope.
15
+ #
16
+ # Streaming operations (SendStreamingMessage, SubscribeToTask):
17
+ # When the handler sets env["a2a.stream"] to an SSE::Stream (which is
18
+ # a Protocol::HTTP::Body::Writable, which is a Readable), Falcon's
19
+ # protocol-rack passes it through untouched. True async streaming
20
+ # with backpressure — no Thread::Queue, no #each polling.
21
+ #
22
+ class JsonRpc
23
+ def initialize(app)
24
+ @app = app
25
+ end
26
+
27
+ def call(env)
28
+ req = Rack::Request.new(env)
29
+ body = req.body.read
30
+ req.body.rewind
31
+
32
+ begin
33
+ rpc = JSON.parse(body)
34
+ rescue JSON::ParserError
35
+ return error_response(nil, -32700, "Parse error")
36
+ end
37
+
38
+ unless rpc.is_a?(Hash) && rpc["jsonrpc"] == "2.0"
39
+ return error_response(nil, -32600, "Invalid Request")
40
+ end
41
+
42
+ id = rpc["id"]
43
+ method = rpc["method"]
44
+ params = rpc["params"] || {}
45
+
46
+ env["a2a.json_rpc_id"] = id
47
+ env["a2a.json_rpc_method"] = method
48
+ env["a2a.body"] = params
49
+
50
+ @app.call(env)
51
+
52
+ # Check if handler signalled a JSON-RPC error
53
+ if (err = env["a2a.error"])
54
+ return error_response(id, err[:code], err[:message], err[:data])
55
+ end
56
+
57
+ # Check if handler set up a streaming response.
58
+ # The stream is an SSE::Stream (Protocol::HTTP::Body::Readable).
59
+ if (stream = env["a2a.stream"])
60
+ return [200, A2A::SSE::Stream.headers, stream]
61
+ end
62
+
63
+ result = env["a2a.result"]
64
+ success_response(id, result)
65
+ end
66
+
67
+ private
68
+
69
+ def success_response(id, result)
70
+ body = result.respond_to?(:to_h) ? result.to_h : (result || {})
71
+ [200, { "content-type" => "application/json" },
72
+ [JSON.generate(jsonrpc: "2.0", id: id, result: body)]]
73
+ end
74
+
75
+ def error_response(id, code, message, data = nil)
76
+ err = { code: code, message: message }
77
+ err[:data] = data if data
78
+ [200, { "content-type" => "application/json" },
79
+ [JSON.generate(jsonrpc: "2.0", id: id, error: err)]]
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ test do
86
+ server = A2A::Server.new(agent_card: { "name" => "Test" })
87
+ rack = Rack::MockRequest.new(server)
88
+
89
+ A2A::Proto.operations.each do |op|
90
+ it "#{op.json_rpc_method} returns valid #{op.response_type}" do
91
+ body = JSON.generate({
92
+ jsonrpc: "2.0",
93
+ id: 1,
94
+ method: op.json_rpc_method,
95
+ params: {}
96
+ })
97
+
98
+ response = rack.post("/a2a", input: body, "CONTENT_TYPE" => "application/json")
99
+ parsed = JSON.parse(response.body)
100
+
101
+ parsed["error"].should.be.nil
102
+
103
+ if op.response_schema
104
+ result = parsed["result"]
105
+ schema_obj = op.response_schema.new(result)
106
+ schema_obj.valid?.should == true
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+ require "a2a/sse"
6
+
7
+ module A2A
8
+ module Bindings
9
+ # Rack middleware implementing the A2A HTTP+JSON/REST protocol binding.
10
+ #
11
+ # Extracts the HTTP verb, path, and request body/params into env keys.
12
+ # Calls downstream. On return, wraps env["a2a.result"] into a REST
13
+ # response with content-type application/a2a+json.
14
+ #
15
+ # Streaming operations:
16
+ # When the handler sets env["a2a.stream"] to an SSE::Stream (which is
17
+ # a Protocol::HTTP::Body::Readable), Falcon streams it natively —
18
+ # no wrapping, no #each conversion, true async with backpressure.
19
+ #
20
+ class Rest
21
+ def initialize(app)
22
+ @app = app
23
+ end
24
+
25
+ def call(env)
26
+ req = Rack::Request.new(env)
27
+
28
+ env["a2a.verb"] = req.request_method.downcase
29
+ env["a2a.path"] = req.path_info
30
+
31
+ params = {}
32
+ if req.post? || req.put? || req.patch?
33
+ begin
34
+ params = JSON.parse(req.body.read) rescue {}
35
+ end
36
+ end
37
+
38
+ # Merge query params for GET/DELETE
39
+ params.merge!(req.params) if req.get? || req.delete?
40
+
41
+ env["a2a.body"] = params
42
+
43
+ @app.call(env)
44
+
45
+ # Check if handler signalled a REST error
46
+ if (err = env["a2a.error"])
47
+ return error_response(err[:http_status] || 400, err[:message], err[:data])
48
+ end
49
+
50
+ # Check if handler set up a streaming response.
51
+ # The stream is an SSE::Stream (Protocol::HTTP::Body::Readable).
52
+ if (stream = env["a2a.stream"])
53
+ return [200, A2A::SSE::Stream.headers, stream]
54
+ end
55
+
56
+ result = env["a2a.result"]
57
+ success_response(result)
58
+ end
59
+
60
+ private
61
+
62
+ def success_response(result)
63
+ body = result.respond_to?(:to_h) ? result.to_h : (result || {})
64
+ [200, { "content-type" => "application/a2a+json" },
65
+ [JSON.generate(body)]]
66
+ end
67
+
68
+ def error_response(status, message, data = nil)
69
+ body = { "type" => "error", "title" => message, "status" => status }
70
+ body["detail"] = data if data
71
+ [status, { "content-type" => "application/problem+json" },
72
+ [JSON.generate(body)]]
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ test do
79
+ server = A2A::Server.new(agent_card: { "name" => "Test" })
80
+ rack = Rack::MockRequest.new(server)
81
+
82
+ A2A::Proto.operations.each do |op|
83
+ it "#{op.rest_verb.upcase} #{op.rest_path} returns valid #{op.response_type}" do
84
+ # Build request path, replacing {id=*} etc with a placeholder value
85
+ path = op.rest_path.gsub(/\{[^}]+\}/, "test-id")
86
+
87
+ input = nil
88
+ if op.http_bindings.first.body
89
+ input = JSON.generate({})
90
+ end
91
+
92
+ response = rack.request(op.rest_verb.upcase, path,
93
+ input: input,
94
+ "CONTENT_TYPE" => "application/a2a+json")
95
+
96
+ parsed = JSON.parse(response.body)
97
+
98
+ parsed["error"].should.be.nil
99
+
100
+ if op.response_schema
101
+ schema_obj = op.response_schema.new(parsed)
102
+ schema_obj.valid?.should == true
103
+ end
104
+ end
105
+ end
106
+ end
data/lib/a2a/client.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ # Async-HTTP based A2A protocol client.
8
+ #
9
+ # Discovers agent cards and invokes operations via JSON-RPC:
10
+ #
11
+ # Async do
12
+ # client = A2A::Client.new("http://localhost:9292")
13
+ # card = client.agent_card
14
+ # result = client.send_message(message: { ... })
15
+ # task = client.get_task(id: "task-123")
16
+ # end
17
+ #
18
+ class Client
19
+ def initialize(url)
20
+ @url = url.chomp("/")
21
+ end
22
+
23
+ # GET /.well-known/agent-card.json
24
+ def agent_card
25
+ get("/.well-known/agent-card.json")
26
+ end
27
+
28
+ # JSON-RPC operations — each maps to a Proto operation name.
29
+ Proto.operations.each do |op|
30
+ method_name = op.name.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
31
+
32
+ define_method(method_name) do |params = {}|
33
+ json_rpc(op.name, params)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def json_rpc(method, params)
40
+ body = JSON.generate(
41
+ jsonrpc: "2.0",
42
+ id: next_id,
43
+ method: method,
44
+ params: params,
45
+ )
46
+
47
+ response = post("/", body, "application/json")
48
+ parsed = JSON.parse(response)
49
+
50
+ if (error = parsed["error"])
51
+ raise "JSON-RPC error #{error["code"]}: #{error["message"]}"
52
+ end
53
+
54
+ parsed["result"]
55
+ end
56
+
57
+ def get(path)
58
+ Async do
59
+ internet = Async::HTTP::Internet.new
60
+ response = internet.get("#{@url}#{path}")
61
+ body = response.read
62
+ internet.close
63
+ JSON.parse(body)
64
+ end.wait
65
+ end
66
+
67
+ def post(path, body, content_type)
68
+ Async do
69
+ internet = Async::HTTP::Internet.new
70
+ response = internet.post(
71
+ "#{@url}#{path}",
72
+ [["content-type", content_type]],
73
+ [body],
74
+ )
75
+ result = response.read
76
+ internet.close
77
+ result
78
+ end.wait
79
+ end
80
+
81
+ def next_id
82
+ @id_counter = (@id_counter || 0) + 1
83
+ end
84
+ end
85
+ end