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/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
|