agent2agent 1.1.1 → 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 +24 -18
- 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 -39
- 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,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/http/body/writable"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
class Server
|
|
8
|
+
module SSE
|
|
9
|
+
# Async-native SSE body built on ::Protocol::HTTP::Body::Writable.
|
|
10
|
+
#
|
|
11
|
+
# Falcon's protocol-rack passes ::Protocol::HTTP::Body::Readable subclasses
|
|
12
|
+
# through untouched — no Enumerable wrapping, no intermediate buffering.
|
|
13
|
+
# This gives us true async streaming with backpressure for free.
|
|
14
|
+
#
|
|
15
|
+
# The gospel (protocol-http) teaches:
|
|
16
|
+
# - Writable is a producer/consumer queue body
|
|
17
|
+
# - write() pushes chunks; read() pops them (blocks when empty)
|
|
18
|
+
# - close_write signals EOF (reader gets nil)
|
|
19
|
+
# - Client disconnect raises on next write()
|
|
20
|
+
#
|
|
21
|
+
# Usage:
|
|
22
|
+
#
|
|
23
|
+
# stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
24
|
+
#
|
|
25
|
+
# Async do
|
|
26
|
+
# stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
|
|
27
|
+
# stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "..." })
|
|
28
|
+
# stream.finish
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# # Return as Rack body — Falcon streams it natively
|
|
32
|
+
# [200, { "content-type" => "text/event-stream" }, stream]
|
|
33
|
+
#
|
|
34
|
+
class Stream < ::Protocol::HTTP::Body::Writable
|
|
35
|
+
SSE_HEADERS = {
|
|
36
|
+
"content-type" => "text/event-stream",
|
|
37
|
+
"cache-control" => "no-cache, no-transform",
|
|
38
|
+
"x-accel-buffering" => "no",
|
|
39
|
+
"connection" => "keep-alive",
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
attr_reader :task_id, :context_id
|
|
43
|
+
|
|
44
|
+
def initialize(task_id:, context_id:, **options)
|
|
45
|
+
@task_id = task_id
|
|
46
|
+
@context_id = context_id
|
|
47
|
+
super(**options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Emit an SSE event.
|
|
51
|
+
#
|
|
52
|
+
# @param data [Hash, String] the event payload (Hashes are JSON-encoded)
|
|
53
|
+
# @param type [String, nil] optional SSE event type field
|
|
54
|
+
# @param id [String, nil] optional SSE event id field
|
|
55
|
+
#
|
|
56
|
+
def event(data, type: nil, id: nil)
|
|
57
|
+
payload = data.is_a?(String) ? data : JSON.generate(data)
|
|
58
|
+
|
|
59
|
+
buf = String.new
|
|
60
|
+
buf << "event: #{type}\n" if type
|
|
61
|
+
buf << "id: #{id}\n" if id
|
|
62
|
+
|
|
63
|
+
# SSE spec: each line of data gets its own `data:` prefix.
|
|
64
|
+
# For single-line JSON this is one line; multi-line is handled correctly.
|
|
65
|
+
payload.each_line do |line|
|
|
66
|
+
buf << "data: #{line.chomp}\n"
|
|
67
|
+
end
|
|
68
|
+
buf << "\n" # blank line terminates the event
|
|
69
|
+
|
|
70
|
+
write(buf)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Signal end-of-stream.
|
|
74
|
+
# The reader will receive nil on next read(), closing the SSE connection.
|
|
75
|
+
def finish
|
|
76
|
+
close_write
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convenience: the SSE headers to return in the Rack response.
|
|
80
|
+
# Returns a fresh copy — Rack response headers must be mutable,
|
|
81
|
+
# since middleware further up the stack (e.g. Rails' RequestId)
|
|
82
|
+
# adds headers in place.
|
|
83
|
+
def self.headers
|
|
84
|
+
SSE_HEADERS.dup
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- Typed event emitters -------------------------------------------
|
|
88
|
+
#
|
|
89
|
+
# Dynamically generated from the StreamResponse schema.
|
|
90
|
+
# Each property in StreamResponse (task, message, statusUpdate,
|
|
91
|
+
# artifactUpdate) gets a snake_case method that:
|
|
92
|
+
#
|
|
93
|
+
# 1. Injects @task_id and @context_id (using the correct key name
|
|
94
|
+
# for each type — Task uses `id`, others use `task_id`)
|
|
95
|
+
# 2. Builds the schema Definition from kwargs
|
|
96
|
+
# 3. Wraps it in the StreamResponse envelope
|
|
97
|
+
# 4. Calls #event to emit the SSE wire format
|
|
98
|
+
#
|
|
99
|
+
# This means you never pass task_id/context_id manually:
|
|
100
|
+
#
|
|
101
|
+
# stream.task(status: { state: "TASK_STATE_WORKING" })
|
|
102
|
+
# stream.artifact_update(artifact: { ... }, append: false, last_chunk: true)
|
|
103
|
+
# stream.status_update(status: { state: "TASK_STATE_COMPLETED" })
|
|
104
|
+
# stream.message(role: "ROLE_AGENT", parts: [{ text: "Hello" }])
|
|
105
|
+
#
|
|
106
|
+
|
|
107
|
+
stream_response = A2A::Protocol::JsonSchema["Stream Response"]
|
|
108
|
+
|
|
109
|
+
stream_response.property_refs.each do |camel_key, (_kind, title)|
|
|
110
|
+
target_props = A2A::Protocol::JsonSchema[title].schema_properties
|
|
111
|
+
|
|
112
|
+
# Task uses `id` for the task identifier; the other three use `taskId`
|
|
113
|
+
task_id_key = target_props.include?("id") ? :id : :task_id
|
|
114
|
+
|
|
115
|
+
# camelCase -> snake_case method name
|
|
116
|
+
method_name = camel_key
|
|
117
|
+
.gsub(/([A-Z])/) { "_#{$1.downcase}" }
|
|
118
|
+
.delete_prefix("_")
|
|
119
|
+
|
|
120
|
+
define_method(method_name) do |**kwargs|
|
|
121
|
+
merged = { task_id_key => @task_id, context_id: @context_id }.merge(kwargs)
|
|
122
|
+
event({ camel_key => A2A::Protocol::JsonSchema[title].new(merged).to_h })
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
__END__
|
|
131
|
+
require "async"
|
|
132
|
+
|
|
133
|
+
describe "A2A::Server::SSE::Stream" do
|
|
134
|
+
it "formats SSE events correctly" do
|
|
135
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
136
|
+
|
|
137
|
+
stream.event({ "task" => { "id" => "t1" } })
|
|
138
|
+
stream.finish
|
|
139
|
+
|
|
140
|
+
chunk = stream.read
|
|
141
|
+
chunk.should.include("data: ")
|
|
142
|
+
chunk.should.include('"task"')
|
|
143
|
+
chunk.should.end_with("\n\n")
|
|
144
|
+
|
|
145
|
+
# After finish, read returns nil
|
|
146
|
+
stream.read.should.be.nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "includes event type when provided" do
|
|
150
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
151
|
+
|
|
152
|
+
stream.event("hello", type: "ping")
|
|
153
|
+
stream.finish
|
|
154
|
+
|
|
155
|
+
chunk = stream.read
|
|
156
|
+
chunk.should.include("event: ping\n")
|
|
157
|
+
chunk.should.include("data: hello\n")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "includes event id when provided" do
|
|
161
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
162
|
+
|
|
163
|
+
stream.event("test", id: "42")
|
|
164
|
+
stream.finish
|
|
165
|
+
|
|
166
|
+
chunk = stream.read
|
|
167
|
+
chunk.should.include("id: 42\n")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "is a ::Protocol::HTTP::Body::Readable" do
|
|
171
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
172
|
+
stream.is_a?(::Protocol::HTTP::Body::Readable).should == true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "provides standard SSE headers" do
|
|
176
|
+
headers = A2A::Server::SSE::Stream.headers
|
|
177
|
+
headers["content-type"].should == "text/event-stream"
|
|
178
|
+
headers["cache-control"].should.include("no-cache")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "stores task_id and context_id" do
|
|
182
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
183
|
+
stream.task_id.should == "t1"
|
|
184
|
+
stream.context_id.should == "c1"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- Typed emit methods ---
|
|
188
|
+
|
|
189
|
+
it "#task emits a task event with injected id and context_id" do
|
|
190
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
191
|
+
|
|
192
|
+
stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "2025-01-01T00:00:00Z" })
|
|
193
|
+
stream.finish
|
|
194
|
+
|
|
195
|
+
chunk = stream.read
|
|
196
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
197
|
+
parsed["task"]["id"].should == "t1"
|
|
198
|
+
parsed["task"]["contextId"].should == "c1"
|
|
199
|
+
parsed["task"]["status"]["state"].should == "TASK_STATE_WORKING"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "#status_update emits with injected task_id and context_id" do
|
|
203
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
204
|
+
|
|
205
|
+
stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
206
|
+
stream.finish
|
|
207
|
+
|
|
208
|
+
chunk = stream.read
|
|
209
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
210
|
+
parsed["statusUpdate"]["taskId"].should == "t1"
|
|
211
|
+
parsed["statusUpdate"]["contextId"].should == "c1"
|
|
212
|
+
parsed["statusUpdate"]["status"]["state"].should == "TASK_STATE_COMPLETED"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "#artifact_update emits with injected task_id and context_id" do
|
|
216
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
217
|
+
|
|
218
|
+
stream.artifact_update(
|
|
219
|
+
artifact: { artifact_id: "a1", parts: [{ text: "hello" }] },
|
|
220
|
+
append: false,
|
|
221
|
+
last_chunk: true
|
|
222
|
+
)
|
|
223
|
+
stream.finish
|
|
224
|
+
|
|
225
|
+
chunk = stream.read
|
|
226
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
227
|
+
parsed["artifactUpdate"]["taskId"].should == "t1"
|
|
228
|
+
parsed["artifactUpdate"]["contextId"].should == "c1"
|
|
229
|
+
parsed["artifactUpdate"]["artifact"]["artifactId"].should == "a1"
|
|
230
|
+
parsed["artifactUpdate"]["append"].should == false
|
|
231
|
+
parsed["artifactUpdate"]["lastChunk"].should == true
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "#message emits with injected task_id and context_id" do
|
|
235
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
236
|
+
|
|
237
|
+
stream.message(
|
|
238
|
+
message_id: "m1",
|
|
239
|
+
role: "ROLE_AGENT",
|
|
240
|
+
parts: [{ text: "Hello" }]
|
|
241
|
+
)
|
|
242
|
+
stream.finish
|
|
243
|
+
|
|
244
|
+
chunk = stream.read
|
|
245
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
246
|
+
parsed["message"]["taskId"].should == "t1"
|
|
247
|
+
parsed["message"]["contextId"].should == "c1"
|
|
248
|
+
parsed["message"]["role"].should == "ROLE_AGENT"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "typed methods allow overriding injected values" do
|
|
252
|
+
stream = A2A::Server::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
253
|
+
|
|
254
|
+
stream.task(id: "override", status: { state: "TASK_STATE_WORKING" })
|
|
255
|
+
stream.finish
|
|
256
|
+
|
|
257
|
+
chunk = stream.read
|
|
258
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
259
|
+
parsed["task"]["id"].should == "override"
|
|
260
|
+
end
|
|
261
|
+
end
|
data/lib/a2a/server/triage.rb
CHANGED
|
@@ -10,7 +10,7 @@ module A2A
|
|
|
10
10
|
# Reads the raw protocol data left in env by the binding middleware
|
|
11
11
|
# (JSON-RPC method name or HTTP verb + path), looks up the matching
|
|
12
12
|
# Proto::Operation, wraps the request body into a Schema::Definition,
|
|
13
|
-
# and sets env keys for downstream consumption by the
|
|
13
|
+
# and sets env keys for downstream consumption by the agent.
|
|
14
14
|
#
|
|
15
15
|
# Env keys read:
|
|
16
16
|
# "a2a.json_rpc_method" — set by Bindings::JsonRpc
|
|
@@ -58,14 +58,14 @@ module A2A
|
|
|
58
58
|
|
|
59
59
|
def resolve_operation(env)
|
|
60
60
|
if env["a2a.json_rpc_method"]
|
|
61
|
-
|
|
61
|
+
Protocol::Protobuf.operation(env["a2a.json_rpc_method"])
|
|
62
62
|
elsif env["a2a.verb"] && env["a2a.path"]
|
|
63
63
|
match_rest_operation(env["a2a.verb"], env["a2a.path"])
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def match_rest_operation(verb, path)
|
|
68
|
-
|
|
68
|
+
Protocol::Protobuf.operations.find do |op|
|
|
69
69
|
op.http_bindings.any? do |b|
|
|
70
70
|
b.verb == verb && path_matches?(b.path, path)
|
|
71
71
|
end
|
|
@@ -80,7 +80,10 @@ module A2A
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def pattern_to_regex(pattern)
|
|
83
|
-
|
|
83
|
+
# Path params must not swallow ":" — custom-method suffixes like
|
|
84
|
+
# "/tasks/{id=*}:subscribe" would otherwise be captured into the
|
|
85
|
+
# id by plainer patterns like "/tasks/{id=*}".
|
|
86
|
+
re = pattern.gsub(/\{[^}]+\}/, '([^/:]+)')
|
|
84
87
|
/\A#{re}\z/
|
|
85
88
|
end
|
|
86
89
|
|
|
@@ -95,3 +98,31 @@ module A2A
|
|
|
95
98
|
end
|
|
96
99
|
end
|
|
97
100
|
end
|
|
101
|
+
|
|
102
|
+
__END__
|
|
103
|
+
describe "A2A::Server::Triage" do
|
|
104
|
+
triage = -> (env) { A2A::Server::Triage.new(->(e) { e }).call(env) }
|
|
105
|
+
|
|
106
|
+
it "resolves GET /tasks/{id} to GetTask" do
|
|
107
|
+
env = triage.({ "a2a.verb" => "get", "a2a.path" => "/tasks/abc-123", "a2a.body" => {} })
|
|
108
|
+
env["a2a.operation"].should == "GetTask"
|
|
109
|
+
env["a2a.request"].id.should == "abc-123"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "resolves GET /tasks/{id}:subscribe to SubscribeToTask, not GetTask" do
|
|
113
|
+
env = triage.({ "a2a.verb" => "get", "a2a.path" => "/tasks/abc-123:subscribe", "a2a.body" => {} })
|
|
114
|
+
env["a2a.operation"].should == "SubscribeToTask"
|
|
115
|
+
env["a2a.request"].id.should == "abc-123"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "resolves POST /tasks/{id}:cancel to CancelTask" do
|
|
119
|
+
env = triage.({ "a2a.verb" => "post", "a2a.path" => "/tasks/abc-123:cancel", "a2a.body" => {} })
|
|
120
|
+
env["a2a.operation"].should == "CancelTask"
|
|
121
|
+
env["a2a.request"].id.should == "abc-123"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "returns UnsupportedOperationError for an unknown path" do
|
|
125
|
+
result = triage.({ "a2a.verb" => "get", "a2a.path" => "/nope", "a2a.body" => {} })
|
|
126
|
+
result.should.be.is_a(A2A::UnsupportedOperationError)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
class Server
|
|
8
|
+
# Rack middleware that serves the agent card at the well-known
|
|
9
|
+
# discovery path. All other requests pass through untouched.
|
|
10
|
+
#
|
|
11
|
+
class WellKnown
|
|
12
|
+
PATH = "/.well-known/agent-card.json"
|
|
13
|
+
|
|
14
|
+
def initialize(app)
|
|
15
|
+
@app = app
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
return @app.call(env) unless env["PATH_INFO"] == PATH
|
|
20
|
+
|
|
21
|
+
card = env["a2a.agent_card"] || {}
|
|
22
|
+
body = card.is_a?(Hash) ? card : card.to_h
|
|
23
|
+
[200, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/a2a/server.rb
CHANGED
|
@@ -4,57 +4,49 @@ require "bundler/setup"
|
|
|
4
4
|
require "a2a"
|
|
5
5
|
|
|
6
6
|
require "a2a/server/env"
|
|
7
|
+
require "a2a/server/well_known"
|
|
7
8
|
require "a2a/server/triage"
|
|
8
|
-
require "a2a/server/dispatcher"
|
|
9
9
|
|
|
10
10
|
module A2A
|
|
11
11
|
# Rack application that exposes an A2A-compliant agent server.
|
|
12
12
|
#
|
|
13
|
-
# Composes
|
|
13
|
+
# Composes a single linear middleware stack. Each middleware checks the
|
|
14
|
+
# request path and either handles the request or passes it downstream:
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# Env → injects shared context into the env
|
|
17
|
+
# WellKnown → serves /.well-known/agent-card.json
|
|
18
|
+
# Bindings::Grpc → /grpc (reserved) → 501 Not Implemented
|
|
19
|
+
# Bindings::Rest → /rest (HTTP+JSON/REST)
|
|
20
|
+
# Bindings::JsonRpc → everything else (JSON-RPC 2.0)
|
|
21
|
+
# SSEStream → offers env["a2a.stream"] to the agent
|
|
22
|
+
# Triage → resolves the target operation
|
|
23
|
+
# ExtractMessage → sets env["a2a.message"] when a message is present
|
|
24
|
+
# LimitHistoryLength → sets env["a2a.history_length"]
|
|
25
|
+
# LimitPaginationSize → sets env["a2a.page_size"]
|
|
26
|
+
# Agent → the terminal app (your block)
|
|
17
27
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
28
|
+
# The block is the terminal rack app — it is executed at the end of
|
|
29
|
+
# the stack, wrapped in the A2A::Agent error boundary. Usage
|
|
30
|
+
# (A2A.agent is a shorthand for this):
|
|
20
31
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# agent = A2A::Agent.new do
|
|
27
|
-
# on "SendMessage" do
|
|
28
|
-
# respond_with -> (env) {
|
|
29
|
-
# A2A::Schema["Send Message Response"].new({})
|
|
30
|
-
# }
|
|
32
|
+
# app = A2A::Server.new(agent_card: { "name" => "My Agent", ... }) do |env|
|
|
33
|
+
# case env["a2a.operation"]
|
|
34
|
+
# in "SendMessage"
|
|
35
|
+
# A2A::Protocol::JsonSchema["Send Message Response"].new({})
|
|
31
36
|
# end
|
|
32
37
|
# end
|
|
33
38
|
#
|
|
34
|
-
# app = A2A::Server.new(agent_card: { "name" => "My Agent", ... })
|
|
35
|
-
# app.register(agent)
|
|
36
|
-
#
|
|
37
39
|
# run app
|
|
38
40
|
#
|
|
39
41
|
class Server
|
|
40
|
-
def initialize(agent_card: {})
|
|
41
|
-
|
|
42
|
-
@dispatcher = Dispatcher.new
|
|
43
|
-
@app = build_app
|
|
44
|
-
end
|
|
42
|
+
def initialize(agent_card: {}, history_length: 100, page_size: 100, &block)
|
|
43
|
+
raise ArgumentError, "Server requires a block" unless block
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
#
|
|
52
|
-
def register(handler)
|
|
53
|
-
if handler.respond_to?(:handlers)
|
|
54
|
-
handler.handlers.each { |h| @dispatcher.register(h) }
|
|
55
|
-
else
|
|
56
|
-
@dispatcher.register(handler)
|
|
57
|
-
end
|
|
45
|
+
@agent_card = agent_card
|
|
46
|
+
@agent = Agent.new(&block)
|
|
47
|
+
@history_length = history_length
|
|
48
|
+
@page_size = page_size
|
|
49
|
+
@app = build_app
|
|
58
50
|
end
|
|
59
51
|
|
|
60
52
|
def call(env)
|
|
@@ -64,34 +56,28 @@ module A2A
|
|
|
64
56
|
private
|
|
65
57
|
|
|
66
58
|
def build_app
|
|
59
|
+
require "a2a/server/bindings/grpc"
|
|
67
60
|
require "a2a/server/bindings/json_rpc"
|
|
68
61
|
require "a2a/server/bindings/rest"
|
|
62
|
+
require "a2a/server/middleware"
|
|
69
63
|
|
|
70
|
-
agent_card
|
|
71
|
-
|
|
64
|
+
agent_card = @agent_card
|
|
65
|
+
agent = @agent
|
|
66
|
+
history_length = @history_length
|
|
67
|
+
page_size = @page_size
|
|
72
68
|
|
|
73
69
|
Rack::Builder.app do
|
|
74
70
|
use A2A::Server::Env, agent_card: agent_card
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
use A2A::Server::Bindings::JsonRpc
|
|
86
|
-
use A2A::Server::Triage
|
|
87
|
-
run dispatcher
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
map "/" do
|
|
91
|
-
use A2A::Server::Bindings::Rest
|
|
92
|
-
use A2A::Server::Triage
|
|
93
|
-
run dispatcher
|
|
94
|
-
end
|
|
71
|
+
use A2A::Server::WellKnown
|
|
72
|
+
use A2A::Server::Bindings::Grpc, path_prefix: "/grpc"
|
|
73
|
+
use A2A::Server::Bindings::Rest, path_prefix: "/rest"
|
|
74
|
+
use A2A::Server::Bindings::JsonRpc, path_prefix: "/"
|
|
75
|
+
use A2A::Server::Middleware::SSEStream
|
|
76
|
+
use A2A::Server::Triage
|
|
77
|
+
use A2A::Server::Middleware::ExtractMessage
|
|
78
|
+
use A2A::Server::Middleware::LimitHistoryLength, history_length
|
|
79
|
+
use A2A::Server::Middleware::LimitPaginationSize, page_size
|
|
80
|
+
run agent
|
|
95
81
|
end
|
|
96
82
|
end
|
|
97
83
|
end
|
data/lib/a2a/test_helpers.rb
CHANGED
|
@@ -2,86 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
require "a2a"
|
|
4
4
|
require "a2a/agent"
|
|
5
|
-
require "a2a/
|
|
6
|
-
require "a2a/
|
|
5
|
+
require "a2a/protocol/protobuf"
|
|
6
|
+
require "a2a/protocol/json_schema"
|
|
7
7
|
|
|
8
8
|
module A2A
|
|
9
9
|
module TestHelpers
|
|
10
|
-
# Returns a stub agent that handles all A2A operations with
|
|
10
|
+
# Returns a stub agent server that handles all A2A operations with
|
|
11
11
|
# minimal valid responses. Useful for integration tests that
|
|
12
12
|
# need a working server without real business logic.
|
|
13
|
-
def self.stub_agent
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
respond_with -> (env) {
|
|
45
|
-
A2A::Schema["Task"].new(
|
|
46
|
-
"id" => "test-id",
|
|
47
|
-
"contextId" => "ctx-1",
|
|
48
|
-
"status" => { "state" => "TASK_STATE_CANCELED", "timestamp" => "2025-01-01T00:00:00Z" }
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
on "SubscribeToTask" do
|
|
54
|
-
respond_with -> (env) {
|
|
55
|
-
A2A::Schema["Stream Response"].new({})
|
|
56
|
-
}
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
on "CreateTaskPushNotificationConfig" do
|
|
60
|
-
respond_with -> (env) {
|
|
61
|
-
A2A::Schema["Task Push Notification Config"].new("url" => "http://example.com")
|
|
62
|
-
}
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
on "GetTaskPushNotificationConfig" do
|
|
66
|
-
respond_with -> (env) {
|
|
67
|
-
A2A::Schema["Task Push Notification Config"].new("url" => "http://example.com")
|
|
68
|
-
}
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
on "ListTaskPushNotificationConfigs" do
|
|
72
|
-
respond_with -> (env) {
|
|
73
|
-
A2A::Schema["List Task Push Notification Configs Response"].new({})
|
|
74
|
-
}
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
on "GetExtendedAgentCard" do
|
|
78
|
-
respond_with -> (env) {
|
|
79
|
-
A2A::Schema["Agent Card"].new("name" => "Test", "version" => "1.0")
|
|
80
|
-
}
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
on "DeleteTaskPushNotificationConfig" do
|
|
84
|
-
respond_with -> (env) { nil }
|
|
13
|
+
def self.stub_agent(agent_card: {})
|
|
14
|
+
schema = A2A::Protocol::JsonSchema
|
|
15
|
+
|
|
16
|
+
A2A.agent(agent_card: agent_card) do |env|
|
|
17
|
+
case env["a2a.operation"]
|
|
18
|
+
in "SendMessage"
|
|
19
|
+
schema["Send Message Response"].new({})
|
|
20
|
+
in "SendStreamingMessage" | "SubscribeToTask"
|
|
21
|
+
schema["Stream Response"].new({})
|
|
22
|
+
in "GetTask"
|
|
23
|
+
schema["Task"].new(
|
|
24
|
+
"id" => "test-id",
|
|
25
|
+
"contextId" => "ctx-1",
|
|
26
|
+
"status" => { "state" => "TASK_STATE_COMPLETED", "timestamp" => "2025-01-01T00:00:00Z" }
|
|
27
|
+
)
|
|
28
|
+
in "CancelTask"
|
|
29
|
+
schema["Task"].new(
|
|
30
|
+
"id" => "test-id",
|
|
31
|
+
"contextId" => "ctx-1",
|
|
32
|
+
"status" => { "state" => "TASK_STATE_CANCELED", "timestamp" => "2025-01-01T00:00:00Z" }
|
|
33
|
+
)
|
|
34
|
+
in "ListTasks"
|
|
35
|
+
schema["List Tasks Response"].new({})
|
|
36
|
+
in "CreateTaskPushNotificationConfig" | "GetTaskPushNotificationConfig"
|
|
37
|
+
schema["Task Push Notification Config"].new("url" => "http://example.com")
|
|
38
|
+
in "ListTaskPushNotificationConfigs"
|
|
39
|
+
schema["List Task Push Notification Configs Response"].new({})
|
|
40
|
+
in "GetExtendedAgentCard"
|
|
41
|
+
schema["Agent Card"].new("name" => "Test", "version" => "1.0")
|
|
42
|
+
in "DeleteTaskPushNotificationConfig"
|
|
43
|
+
nil
|
|
85
44
|
end
|
|
86
45
|
end
|
|
87
46
|
end
|
data/lib/a2a/version.rb
CHANGED
data/lib/a2a.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
|
-
require "scampi"
|
|
5
4
|
require "json"
|
|
6
5
|
require "json_schemer"
|
|
7
6
|
require "async"
|
|
@@ -13,18 +12,15 @@ module A2A
|
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
require "a2a/errors"
|
|
16
|
-
require "a2a/
|
|
17
|
-
require "a2a/
|
|
18
|
-
require "a2a/schema/definition"
|
|
19
|
-
require "a2a/schema/validation_error"
|
|
20
|
-
require "a2a/sse"
|
|
15
|
+
require "a2a/protocol"
|
|
16
|
+
require "a2a/server/sse"
|
|
21
17
|
require "a2a/agent"
|
|
22
18
|
|
|
23
19
|
require "a2a/server"
|
|
24
20
|
|
|
25
|
-
require "a2a/
|
|
26
|
-
require "a2a/
|
|
27
|
-
require "a2a/
|
|
28
|
-
require "a2a/
|
|
29
|
-
require "a2a/
|
|
21
|
+
require "a2a/client/middleware/schema_request"
|
|
22
|
+
require "a2a/client/middleware/json_rpc/request"
|
|
23
|
+
require "a2a/client/middleware/json_rpc/response"
|
|
24
|
+
require "a2a/client/middleware/rest/request"
|
|
25
|
+
require "a2a/client/middleware/rest/response"
|
|
30
26
|
require "a2a/client"
|