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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
require "a2a/server/sse"
|
|
6
|
+
require "async"
|
|
7
|
+
|
|
8
|
+
module A2A
|
|
9
|
+
class Server
|
|
10
|
+
module Middleware
|
|
11
|
+
# Sets up an SSE stream builder on `env["a2a.stream"]`.
|
|
12
|
+
#
|
|
13
|
+
# The builder detects the protocol binding (REST vs JSON-RPC) from
|
|
14
|
+
# env and creates the correct stream subclass when `open` is called.
|
|
15
|
+
# The `open` block runs inside an Async fiber and the stream is
|
|
16
|
+
# automatically finished when the block exits (even on exception).
|
|
17
|
+
#
|
|
18
|
+
# If the agent never calls `open`, the builder is removed from env
|
|
19
|
+
# so the binding layer doesn't mistake it for a real stream.
|
|
20
|
+
#
|
|
21
|
+
# Part of the A2A::Server middleware stack, so the agent gets
|
|
22
|
+
# env["a2a.stream"] without declaring anything.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
#
|
|
26
|
+
# case env["a2a.operation"]
|
|
27
|
+
# in "SendStreamingMessage"
|
|
28
|
+
# env["a2a.stream"].open(task_id: "t1", context_id: "c1") do |s|
|
|
29
|
+
# s.task(status: { state: "TASK_STATE_WORKING" })
|
|
30
|
+
# s.status_update(status: { state: "TASK_STATE_COMPLETED" })
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
class SSEStream
|
|
35
|
+
def initialize(app)
|
|
36
|
+
@app = app
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(env)
|
|
40
|
+
builder = StreamBuilder.new(env)
|
|
41
|
+
env["a2a.stream"] = builder
|
|
42
|
+
|
|
43
|
+
result = @app.call(env)
|
|
44
|
+
|
|
45
|
+
# If open was never called, clear the builder so the binding
|
|
46
|
+
# layer doesn't mistake it for a real stream.
|
|
47
|
+
env.delete("a2a.stream") if env["a2a.stream"].equal?(builder)
|
|
48
|
+
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Factory that creates the correct SSE stream subclass based on the
|
|
54
|
+
# protocol binding, then runs the caller's block inside Async with
|
|
55
|
+
# automatic finish on exit.
|
|
56
|
+
#
|
|
57
|
+
# Created by SSEStream middleware — not intended for direct use.
|
|
58
|
+
#
|
|
59
|
+
class StreamBuilder
|
|
60
|
+
def initialize(env)
|
|
61
|
+
@env = env
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Create and open an SSE stream for the current request.
|
|
65
|
+
#
|
|
66
|
+
# Detects REST vs JSON-RPC from the env, constructs the correct
|
|
67
|
+
# stream subclass, and yields it to the block. The block runs
|
|
68
|
+
# inside an Async fiber. The stream is automatically finished
|
|
69
|
+
# when the block exits, even if an exception is raised.
|
|
70
|
+
#
|
|
71
|
+
# @param task_id [String] the task identifier for this stream
|
|
72
|
+
# @param context_id [String] the context identifier for this stream
|
|
73
|
+
# @yieldparam stream [A2A::Server::SSE::Stream] the opened stream
|
|
74
|
+
# @return [nil]
|
|
75
|
+
#
|
|
76
|
+
def open(task_id:, context_id:, &block)
|
|
77
|
+
stream = if @env["a2a.json_rpc_id"]
|
|
78
|
+
A2A::Server::SSE::JsonRpcStream.new(
|
|
79
|
+
task_id: task_id, context_id: context_id,
|
|
80
|
+
json_rpc_id: @env["a2a.json_rpc_id"]
|
|
81
|
+
)
|
|
82
|
+
else
|
|
83
|
+
A2A::Server::SSE::RestStream.new(
|
|
84
|
+
task_id: task_id, context_id: context_id
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@env["a2a.stream"] = stream
|
|
89
|
+
|
|
90
|
+
Async do
|
|
91
|
+
block.call(stream)
|
|
92
|
+
ensure
|
|
93
|
+
stream.finish
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
__END__
|
|
104
|
+
describe "A2A::Server::Middleware::SSEStream" do
|
|
105
|
+
it "sets a StreamBuilder on env[\"a2a.stream\"]" do
|
|
106
|
+
seen_stream = nil
|
|
107
|
+
downstream = ->(env) { seen_stream = env["a2a.stream"]; :ok }
|
|
108
|
+
|
|
109
|
+
mw = A2A::Server::Middleware::SSEStream.new(downstream)
|
|
110
|
+
env = {}
|
|
111
|
+
mw.call(env)
|
|
112
|
+
|
|
113
|
+
seen_stream.should.be.kind_of(A2A::Server::Middleware::StreamBuilder)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "auto-clears builder if open was never called" do
|
|
117
|
+
downstream = ->(env) { :ok }
|
|
118
|
+
|
|
119
|
+
mw = A2A::Server::Middleware::SSEStream.new(downstream)
|
|
120
|
+
env = {}
|
|
121
|
+
mw.call(env)
|
|
122
|
+
|
|
123
|
+
env.key?("a2a.stream").should == false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "preserves env[\"a2a.stream\"] when open was called" do
|
|
127
|
+
downstream = ->(env) {
|
|
128
|
+
env["a2a.stream"].open(task_id: "t1", context_id: "c1") do |s|
|
|
129
|
+
# no-op
|
|
130
|
+
end
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
mw = A2A::Server::Middleware::SSEStream.new(downstream)
|
|
134
|
+
env = {}
|
|
135
|
+
mw.call(env)
|
|
136
|
+
|
|
137
|
+
env["a2a.stream"].should.be.kind_of(A2A::Server::SSE::RestStream)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "returns the downstream result" do
|
|
141
|
+
downstream = ->(env) { :result_value }
|
|
142
|
+
|
|
143
|
+
mw = A2A::Server::Middleware::SSEStream.new(downstream)
|
|
144
|
+
result = mw.call({})
|
|
145
|
+
|
|
146
|
+
result.should == :result_value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
describe "A2A::Server::Middleware::StreamBuilder" do
|
|
151
|
+
it "creates a RestStream when no JSON-RPC ID" do
|
|
152
|
+
env = {}
|
|
153
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
154
|
+
|
|
155
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
156
|
+
s.should.be.kind_of(A2A::Server::SSE::RestStream)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
env["a2a.stream"].should.be.kind_of(A2A::Server::SSE::RestStream)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "creates a JsonRpcStream when JSON-RPC ID is present" do
|
|
163
|
+
env = { "a2a.json_rpc_id" => 42 }
|
|
164
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
165
|
+
|
|
166
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
167
|
+
s.should.be.kind_of(A2A::Server::SSE::JsonRpcStream)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
env["a2a.stream"].should.be.kind_of(A2A::Server::SSE::JsonRpcStream)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "passes task_id and context_id to the stream" do
|
|
174
|
+
env = {}
|
|
175
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
176
|
+
|
|
177
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
178
|
+
s.task_id.should == "t1"
|
|
179
|
+
s.context_id.should == "c1"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "auto-finishes the stream when block completes" do
|
|
184
|
+
env = {}
|
|
185
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
186
|
+
|
|
187
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
188
|
+
s.task(status: { state: "TASK_STATE_WORKING" })
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
stream = env["a2a.stream"]
|
|
192
|
+
# After finish, read drains remaining data then returns nil
|
|
193
|
+
stream.read # the task event
|
|
194
|
+
stream.read.should.be.nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "auto-finishes even when block raises" do
|
|
198
|
+
env = {}
|
|
199
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
200
|
+
|
|
201
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
202
|
+
s.task(status: { state: "TASK_STATE_WORKING" })
|
|
203
|
+
raise "boom"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
stream = env["a2a.stream"]
|
|
207
|
+
stream.read # the task event
|
|
208
|
+
stream.read.should.be.nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "returns nil" do
|
|
212
|
+
env = {}
|
|
213
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
214
|
+
|
|
215
|
+
result = builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
216
|
+
# no-op
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
result.should.be.nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "typed methods inject task_id and context_id" do
|
|
223
|
+
env = {}
|
|
224
|
+
builder = A2A::Server::Middleware::StreamBuilder.new(env)
|
|
225
|
+
|
|
226
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
227
|
+
s.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
stream = env["a2a.stream"]
|
|
231
|
+
chunk = stream.read
|
|
232
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
233
|
+
parsed["statusUpdate"]["taskId"].should == "t1"
|
|
234
|
+
parsed["statusUpdate"]["contextId"].should == "c1"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -3,80 +3,82 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module A2A
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# Usage:
|
|
19
|
-
#
|
|
20
|
-
# parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
21
|
-
#
|
|
22
|
-
# parser.feed("data: {\"task\":{\"id\":\"t1\"}}\n\n") do |event|
|
|
23
|
-
# event.task.id #=> "t1"
|
|
24
|
-
# end
|
|
25
|
-
#
|
|
26
|
-
class EventParser
|
|
27
|
-
def initialize(binding:)
|
|
28
|
-
@binding = binding
|
|
29
|
-
@buffer = String.new
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Feed a raw chunk of SSE text. Yields a StreamResponse for each
|
|
33
|
-
# complete event found in the buffer.
|
|
6
|
+
class Server
|
|
7
|
+
module SSE
|
|
8
|
+
# Parses raw SSE text chunks into typed StreamResponse schema objects.
|
|
9
|
+
#
|
|
10
|
+
# Faraday's `on_data` callback delivers raw text in arbitrary-sized
|
|
11
|
+
# chunks. This parser buffers them, extracts complete SSE events
|
|
12
|
+
# (delimited by blank lines), parses the JSON from `data:` lines,
|
|
13
|
+
# and yields A2A::Protocol::JsonSchema["Stream Response"] instances.
|
|
14
|
+
#
|
|
15
|
+
# For the JSON-RPC binding, the parser unwraps the JSON-RPC 2.0
|
|
16
|
+
# envelope (`{"jsonrpc":"2.0","id":N,"result":{...}}`) to extract
|
|
17
|
+
# the StreamResponse payload from `result`.
|
|
34
18
|
#
|
|
35
|
-
#
|
|
36
|
-
# @yieldparam event [A2A::Schema::Definition] a Stream Response instance
|
|
19
|
+
# Usage:
|
|
37
20
|
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
21
|
+
# parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
22
|
+
#
|
|
23
|
+
# parser.feed("data: {\"task\":{\"id\":\"t1\"}}\n\n") do |event|
|
|
24
|
+
# event.task.id #=> "t1"
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
class EventParser
|
|
28
|
+
def initialize(binding:)
|
|
29
|
+
@binding = binding
|
|
30
|
+
@buffer = String.new
|
|
48
31
|
end
|
|
49
|
-
end
|
|
50
32
|
|
|
51
|
-
|
|
33
|
+
# Feed a raw chunk of SSE text. Yields a StreamResponse for each
|
|
34
|
+
# complete event found in the buffer.
|
|
35
|
+
#
|
|
36
|
+
# @param chunk [String] raw SSE text from the wire
|
|
37
|
+
# @yieldparam event [A2A::Protocol::JsonSchema::Definition] a Stream Response instance
|
|
38
|
+
#
|
|
39
|
+
def feed(chunk)
|
|
40
|
+
@buffer << chunk
|
|
41
|
+
|
|
42
|
+
# SSE events are terminated by a blank line (\n\n).
|
|
43
|
+
# We split on that boundary, keeping any incomplete trailing data
|
|
44
|
+
# in the buffer for the next call.
|
|
45
|
+
while (idx = @buffer.index("\n\n"))
|
|
46
|
+
raw_event = @buffer.slice!(0, idx + 2)
|
|
47
|
+
payload = parse_event(raw_event)
|
|
48
|
+
yield A2A::Protocol::JsonSchema["Stream Response"].new(payload) if payload
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
# Extract JSON from `data:` lines and optionally unwrap JSON-RPC.
|
|
55
|
+
def parse_event(raw)
|
|
56
|
+
data_lines = raw.each_line
|
|
57
|
+
.select { |line| line.start_with?("data:") }
|
|
58
|
+
.map { |line| line.sub(/\Adata:\s?/, "").chomp }
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
return nil if data_lines.empty?
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
parsed = JSON.parse(data_lines.join("\n"))
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
case @binding
|
|
65
|
+
when :json_rpc then parsed["result"]
|
|
66
|
+
else parsed
|
|
67
|
+
end
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
nil
|
|
66
70
|
end
|
|
67
|
-
|
|
68
|
-
nil
|
|
69
|
-
end
|
|
71
|
+
end
|
|
70
72
|
end
|
|
71
|
-
|
|
73
|
+
end
|
|
72
74
|
end
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
describe "A2A::SSE::EventParser" do
|
|
76
|
+
__END__
|
|
77
|
+
describe "A2A::Server::SSE::EventParser" do
|
|
76
78
|
# --- REST binding ---
|
|
77
79
|
|
|
78
80
|
it "parses a single complete SSE event" do
|
|
79
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
81
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
80
82
|
events = []
|
|
81
83
|
|
|
82
84
|
parser.feed("data: {\"task\":{\"id\":\"t1\",\"contextId\":\"c1\"}}\n\n") do |event|
|
|
@@ -84,13 +86,13 @@ test do
|
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
events.size.should == 1
|
|
87
|
-
events[0].should.be.kind_of(A2A::
|
|
89
|
+
events[0].should.be.kind_of(A2A::Protocol::JsonSchema::Definition)
|
|
88
90
|
events[0].task.should.not.be.nil
|
|
89
91
|
events[0].task.id.should == "t1"
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
it "parses multiple events in a single chunk" do
|
|
93
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
95
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
94
96
|
events = []
|
|
95
97
|
|
|
96
98
|
chunk = [
|
|
@@ -106,7 +108,7 @@ test do
|
|
|
106
108
|
end
|
|
107
109
|
|
|
108
110
|
it "handles partial chunks via buffering" do
|
|
109
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
111
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
110
112
|
events = []
|
|
111
113
|
|
|
112
114
|
# First chunk: incomplete event
|
|
@@ -120,7 +122,7 @@ test do
|
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
it "handles events with event: and id: fields" do
|
|
123
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
125
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
124
126
|
events = []
|
|
125
127
|
|
|
126
128
|
chunk = "event: message\nid: 42\ndata: {\"task\":{\"id\":\"t1\",\"contextId\":\"c1\"}}\n\n"
|
|
@@ -131,7 +133,7 @@ test do
|
|
|
131
133
|
end
|
|
132
134
|
|
|
133
135
|
it "skips events with no data lines" do
|
|
134
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
136
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
135
137
|
events = []
|
|
136
138
|
|
|
137
139
|
parser.feed(": comment\n\n") { |e| events << e }
|
|
@@ -139,7 +141,7 @@ test do
|
|
|
139
141
|
end
|
|
140
142
|
|
|
141
143
|
it "skips events with invalid JSON" do
|
|
142
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
144
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
143
145
|
events = []
|
|
144
146
|
|
|
145
147
|
parser.feed("data: not-json\n\n") { |e| events << e }
|
|
@@ -149,7 +151,7 @@ test do
|
|
|
149
151
|
# --- JSON-RPC binding ---
|
|
150
152
|
|
|
151
153
|
it "unwraps JSON-RPC 2.0 envelope" do
|
|
152
|
-
parser = A2A::SSE::EventParser.new(binding: :json_rpc)
|
|
154
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :json_rpc)
|
|
153
155
|
events = []
|
|
154
156
|
|
|
155
157
|
envelope = {
|
|
@@ -168,7 +170,7 @@ test do
|
|
|
168
170
|
end
|
|
169
171
|
|
|
170
172
|
it "handles multiple JSON-RPC events" do
|
|
171
|
-
parser = A2A::SSE::EventParser.new(binding: :json_rpc)
|
|
173
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :json_rpc)
|
|
172
174
|
events = []
|
|
173
175
|
|
|
174
176
|
[
|
|
@@ -186,7 +188,7 @@ test do
|
|
|
186
188
|
# --- Multi-line data ---
|
|
187
189
|
|
|
188
190
|
it "handles multi-line data fields" do
|
|
189
|
-
parser = A2A::SSE::EventParser.new(binding: :rest)
|
|
191
|
+
parser = A2A::Server::SSE::EventParser.new(binding: :rest)
|
|
190
192
|
events = []
|
|
191
193
|
|
|
192
194
|
# JSON split across multiple data: lines
|
|
@@ -199,4 +201,3 @@ test do
|
|
|
199
201
|
events[0].task.id.should == "t1"
|
|
200
202
|
end
|
|
201
203
|
end
|
|
202
|
-
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stream"
|
|
4
|
+
|
|
5
|
+
module A2A
|
|
6
|
+
class Server
|
|
7
|
+
module SSE
|
|
8
|
+
# SSE stream that wraps each event in a JSON-RPC 2.0 response envelope.
|
|
9
|
+
#
|
|
10
|
+
# Per the A2A spec, JSON-RPC streaming returns SSE where each `data:` line
|
|
11
|
+
# is a full JSON-RPC response: {"jsonrpc":"2.0","id":N,"result":{...}}
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
#
|
|
15
|
+
# stream = A2A::Server::SSE::JsonRpcStream.new(
|
|
16
|
+
# task_id: "t1", context_id: "c1", json_rpc_id: 1
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# Async do
|
|
20
|
+
# stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
|
|
21
|
+
# stream.finish
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# [200, A2A::Server::SSE::Stream.headers, stream]
|
|
25
|
+
#
|
|
26
|
+
class JsonRpcStream < Stream
|
|
27
|
+
def initialize(json_rpc_id:, **options)
|
|
28
|
+
@json_rpc_id = json_rpc_id
|
|
29
|
+
super(**options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Emit an SSE event wrapped in a JSON-RPC envelope.
|
|
33
|
+
#
|
|
34
|
+
# @param data [Hash] the StreamResponse payload (becomes "result")
|
|
35
|
+
#
|
|
36
|
+
def event(data, **opts)
|
|
37
|
+
envelope = {
|
|
38
|
+
"jsonrpc" => "2.0",
|
|
39
|
+
"id" => @json_rpc_id,
|
|
40
|
+
"result" => data.respond_to?(:to_h) ? data.to_h : data,
|
|
41
|
+
}
|
|
42
|
+
super(envelope, **opts)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
__END__
|
|
50
|
+
describe "A2A::Server::SSE::JsonRpcStream" do
|
|
51
|
+
it "wraps events in JSON-RPC 2.0 envelopes" do
|
|
52
|
+
stream = A2A::Server::SSE::JsonRpcStream.new(
|
|
53
|
+
task_id: "t1", context_id: "c1", json_rpc_id: 42
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "2025-01-01T00:00:00Z" })
|
|
57
|
+
stream.finish
|
|
58
|
+
|
|
59
|
+
chunk = stream.read
|
|
60
|
+
chunk.should.include('"jsonrpc"')
|
|
61
|
+
chunk.should.include('"2.0"')
|
|
62
|
+
chunk.should.include('"id":42') # numeric id preserved
|
|
63
|
+
chunk.should.include('"result"')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "is a subclass of SSE::Stream" do
|
|
67
|
+
stream = A2A::Server::SSE::JsonRpcStream.new(
|
|
68
|
+
task_id: "t1", context_id: "c1", json_rpc_id: 1
|
|
69
|
+
)
|
|
70
|
+
stream.is_a?(A2A::Server::SSE::Stream).should == true
|
|
71
|
+
stream.is_a?(::Protocol::HTTP::Body::Readable).should == true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "typed methods chain through JSON-RPC envelope" do
|
|
75
|
+
stream = A2A::Server::SSE::JsonRpcStream.new(
|
|
76
|
+
task_id: "t1", context_id: "c1", json_rpc_id: 7
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
80
|
+
stream.finish
|
|
81
|
+
|
|
82
|
+
chunk = stream.read
|
|
83
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
84
|
+
parsed["jsonrpc"].should == "2.0"
|
|
85
|
+
parsed["id"].should == 7
|
|
86
|
+
parsed["result"]["statusUpdate"]["taskId"].should == "t1"
|
|
87
|
+
parsed["result"]["statusUpdate"]["contextId"].should == "c1"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stream"
|
|
4
|
+
|
|
5
|
+
module A2A
|
|
6
|
+
class Server
|
|
7
|
+
module SSE
|
|
8
|
+
# SSE stream for the HTTP+JSON/REST binding.
|
|
9
|
+
#
|
|
10
|
+
# Per the A2A spec, REST streaming returns SSE where each `data:` line
|
|
11
|
+
# is a bare StreamResponse JSON object (no envelope wrapping).
|
|
12
|
+
#
|
|
13
|
+
# This is essentially just an alias for Stream — the base class already
|
|
14
|
+
# emits bare JSON. We keep this as a named class for symmetry with
|
|
15
|
+
# JsonRpcStream and to make binding code self-documenting.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
#
|
|
19
|
+
# stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
20
|
+
#
|
|
21
|
+
# Async do
|
|
22
|
+
# stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
|
|
23
|
+
# stream.finish
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# [200, A2A::Server::SSE::Stream.headers, stream]
|
|
27
|
+
#
|
|
28
|
+
class RestStream < Stream
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
__END__
|
|
35
|
+
describe "A2A::Server::SSE::RestStream" do
|
|
36
|
+
it "emits bare JSON events (no envelope)" do
|
|
37
|
+
stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
38
|
+
|
|
39
|
+
stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
40
|
+
stream.finish
|
|
41
|
+
|
|
42
|
+
chunk = stream.read
|
|
43
|
+
chunk.should.include('"statusUpdate"')
|
|
44
|
+
chunk.should.not.include('"jsonrpc"')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "is a subclass of SSE::Stream" do
|
|
48
|
+
stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
49
|
+
stream.is_a?(A2A::Server::SSE::Stream).should == true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "injects task_id and context_id into typed events" do
|
|
53
|
+
stream = A2A::Server::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
54
|
+
|
|
55
|
+
stream.task(status: { state: "TASK_STATE_WORKING" })
|
|
56
|
+
stream.finish
|
|
57
|
+
|
|
58
|
+
chunk = stream.read
|
|
59
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
60
|
+
parsed["task"]["id"].should == "t1"
|
|
61
|
+
parsed["task"]["contextId"].should == "c1"
|
|
62
|
+
end
|
|
63
|
+
end
|