agent2agent 1.0.8 → 1.1.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 +165 -117
- data/lib/a2a/client.rb +470 -51
- data/lib/a2a/errors/json_rpc_error.rb +71 -0
- data/lib/a2a/errors/rest_error.rb +68 -0
- data/lib/a2a/errors.rb +535 -0
- data/lib/a2a/faraday/middleware/json_rpc/request.rb +96 -0
- data/lib/a2a/faraday/middleware/json_rpc/response.rb +131 -0
- data/lib/a2a/faraday/middleware/rest/request.rb +166 -0
- data/lib/a2a/faraday/middleware/rest/response.rb +144 -0
- data/lib/a2a/faraday/middleware/schema_request.rb +69 -0
- data/lib/a2a/middleware/extract_message.rb +120 -0
- data/lib/a2a/middleware/fetch_task.rb +228 -0
- data/lib/a2a/middleware/limit_history_length.rb +123 -0
- data/lib/a2a/middleware/limit_pagination_size.rb +133 -0
- data/lib/a2a/middleware/sse_stream.rb +235 -0
- data/lib/a2a/middleware.rb +7 -0
- data/lib/a2a/schema/definition.rb +35 -1
- data/lib/a2a/schema.rb +126 -0
- data/lib/a2a/{bindings → server/bindings}/json_rpc.rb +12 -8
- data/lib/a2a/{bindings → server/bindings}/rest.rb +12 -8
- data/lib/a2a/server/dispatcher.rb +52 -54
- data/lib/a2a/server/env.rb +4 -6
- data/lib/a2a/server/triage.rb +1 -1
- data/lib/a2a/server.rb +10 -10
- data/lib/a2a/sse/event_parser.rb +202 -0
- data/lib/a2a/sse/json_rpc_stream.rb +27 -5
- data/lib/a2a/sse/rest_stream.rb +17 -5
- data/lib/a2a/sse/stream.rb +135 -7
- data/lib/a2a/sse.rb +1 -0
- data/lib/a2a/test_helpers.rb +89 -0
- data/lib/a2a/version.rb +1 -1
- data/lib/a2a.rb +6 -2
- data/lib/traces/provider/a2a/{bindings → server/bindings}/json_rpc.rb +2 -2
- data/lib/traces/provider/a2a/{bindings → server/bindings}/rest.rb +2 -2
- data/lib/traces/provider/a2a.rb +2 -2
- metadata +49 -22
- data/lib/a2a/server/cancel_task.rb +0 -14
- data/lib/a2a/server/create_task_push_notification_config.rb +0 -14
- data/lib/a2a/server/delete_task_push_notification_config.rb +0 -14
- data/lib/a2a/server/get_extended_agent_card.rb +0 -15
- data/lib/a2a/server/get_task.rb +0 -14
- data/lib/a2a/server/get_task_push_notification_config.rb +0 -14
- data/lib/a2a/server/list_task_push_notification_configs.rb +0 -14
- data/lib/a2a/server/list_tasks.rb +0 -14
- data/lib/a2a/server/send_message.rb +0 -14
- data/lib/a2a/server/send_streaming_message.rb +0 -14
- data/lib/a2a/server/subscribe_to_task.rb +0 -14
- data/lib/a2a/store/processor.rb +0 -136
- data/lib/a2a/store/pub_sub.rb +0 -149
- data/lib/a2a/store/sqlite.rb +0 -533
- data/lib/a2a/store/webhooks.rb +0 -94
- data/lib/a2a/store.rb +0 -6
- data/lib/a2a/task_store.rb +0 -315
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
require "a2a/sse"
|
|
6
|
+
require "async"
|
|
7
|
+
|
|
8
|
+
module A2A
|
|
9
|
+
module Middleware
|
|
10
|
+
# Sets up an SSE stream builder on `env["a2a.stream"]`.
|
|
11
|
+
#
|
|
12
|
+
# The builder detects the protocol binding (REST vs JSON-RPC) from
|
|
13
|
+
# env and creates the correct stream subclass when `open` is called.
|
|
14
|
+
# The `open` block runs inside an Async fiber and the stream is
|
|
15
|
+
# automatically finished when the block exits (even on exception).
|
|
16
|
+
#
|
|
17
|
+
# If the handler never calls `open`, the builder is removed from env
|
|
18
|
+
# so the binding layer doesn't mistake it for a real stream.
|
|
19
|
+
#
|
|
20
|
+
# Usage:
|
|
21
|
+
#
|
|
22
|
+
# on "SendStreamingMessage" do
|
|
23
|
+
# use A2A::Middleware::SSEStream
|
|
24
|
+
# use A2A::Middleware::ExtractMessage
|
|
25
|
+
# respond_with -> (env) {
|
|
26
|
+
# env["a2a.stream"].open(task_id: "t1", context_id: "c1") do |s|
|
|
27
|
+
# s.task(status: { state: "TASK_STATE_WORKING" })
|
|
28
|
+
# s.status_update(status: { state: "TASK_STATE_COMPLETED" })
|
|
29
|
+
# end
|
|
30
|
+
# }
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
class SSEStream
|
|
34
|
+
def initialize(app)
|
|
35
|
+
@app = app
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(env)
|
|
39
|
+
builder = StreamBuilder.new(env)
|
|
40
|
+
env["a2a.stream"] = builder
|
|
41
|
+
|
|
42
|
+
result = @app.call(env)
|
|
43
|
+
|
|
44
|
+
# If open was never called, clear the builder so the binding
|
|
45
|
+
# layer doesn't mistake it for a real stream.
|
|
46
|
+
env.delete("a2a.stream") if env["a2a.stream"].equal?(builder)
|
|
47
|
+
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Factory that creates the correct SSE stream subclass based on the
|
|
53
|
+
# protocol binding, then runs the caller's block inside Async with
|
|
54
|
+
# automatic finish on exit.
|
|
55
|
+
#
|
|
56
|
+
# Created by SSEStream middleware — not intended for direct use.
|
|
57
|
+
#
|
|
58
|
+
class StreamBuilder
|
|
59
|
+
def initialize(env)
|
|
60
|
+
@env = env
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Create and open an SSE stream for the current request.
|
|
64
|
+
#
|
|
65
|
+
# Detects REST vs JSON-RPC from the env, constructs the correct
|
|
66
|
+
# stream subclass, and yields it to the block. The block runs
|
|
67
|
+
# inside an Async fiber. The stream is automatically finished
|
|
68
|
+
# when the block exits, even if an exception is raised.
|
|
69
|
+
#
|
|
70
|
+
# @param task_id [String] the task identifier for this stream
|
|
71
|
+
# @param context_id [String] the context identifier for this stream
|
|
72
|
+
# @yieldparam stream [A2A::SSE::Stream] the opened stream
|
|
73
|
+
# @return [nil]
|
|
74
|
+
#
|
|
75
|
+
def open(task_id:, context_id:, &block)
|
|
76
|
+
stream = if @env["a2a.json_rpc_id"]
|
|
77
|
+
A2A::SSE::JsonRpcStream.new(
|
|
78
|
+
task_id: task_id, context_id: context_id,
|
|
79
|
+
json_rpc_id: @env["a2a.json_rpc_id"]
|
|
80
|
+
)
|
|
81
|
+
else
|
|
82
|
+
A2A::SSE::RestStream.new(
|
|
83
|
+
task_id: task_id, context_id: context_id
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@env["a2a.stream"] = stream
|
|
88
|
+
|
|
89
|
+
Async do
|
|
90
|
+
block.call(stream)
|
|
91
|
+
ensure
|
|
92
|
+
stream.finish
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
test do
|
|
102
|
+
describe "A2A::Middleware::SSEStream" do
|
|
103
|
+
it "sets a StreamBuilder on env[\"a2a.stream\"]" do
|
|
104
|
+
seen_stream = nil
|
|
105
|
+
downstream = ->(env) { seen_stream = env["a2a.stream"]; :ok }
|
|
106
|
+
|
|
107
|
+
mw = A2A::Middleware::SSEStream.new(downstream)
|
|
108
|
+
env = {}
|
|
109
|
+
mw.call(env)
|
|
110
|
+
|
|
111
|
+
seen_stream.should.be.kind_of(A2A::Middleware::StreamBuilder)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "auto-clears builder if open was never called" do
|
|
115
|
+
downstream = ->(env) { :ok }
|
|
116
|
+
|
|
117
|
+
mw = A2A::Middleware::SSEStream.new(downstream)
|
|
118
|
+
env = {}
|
|
119
|
+
mw.call(env)
|
|
120
|
+
|
|
121
|
+
env.key?("a2a.stream").should == false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "preserves env[\"a2a.stream\"] when open was called" do
|
|
125
|
+
downstream = ->(env) {
|
|
126
|
+
env["a2a.stream"].open(task_id: "t1", context_id: "c1") do |s|
|
|
127
|
+
# no-op
|
|
128
|
+
end
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
mw = A2A::Middleware::SSEStream.new(downstream)
|
|
132
|
+
env = {}
|
|
133
|
+
mw.call(env)
|
|
134
|
+
|
|
135
|
+
env["a2a.stream"].should.be.kind_of(A2A::SSE::RestStream)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "returns the downstream result" do
|
|
139
|
+
downstream = ->(env) { :result_value }
|
|
140
|
+
|
|
141
|
+
mw = A2A::Middleware::SSEStream.new(downstream)
|
|
142
|
+
result = mw.call({})
|
|
143
|
+
|
|
144
|
+
result.should == :result_value
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe "A2A::Middleware::StreamBuilder" do
|
|
149
|
+
it "creates a RestStream when no JSON-RPC ID" do
|
|
150
|
+
env = {}
|
|
151
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
152
|
+
|
|
153
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
154
|
+
s.should.be.kind_of(A2A::SSE::RestStream)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
env["a2a.stream"].should.be.kind_of(A2A::SSE::RestStream)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "creates a JsonRpcStream when JSON-RPC ID is present" do
|
|
161
|
+
env = { "a2a.json_rpc_id" => 42 }
|
|
162
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
163
|
+
|
|
164
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
165
|
+
s.should.be.kind_of(A2A::SSE::JsonRpcStream)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
env["a2a.stream"].should.be.kind_of(A2A::SSE::JsonRpcStream)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "passes task_id and context_id to the stream" do
|
|
172
|
+
env = {}
|
|
173
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
174
|
+
|
|
175
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
176
|
+
s.task_id.should == "t1"
|
|
177
|
+
s.context_id.should == "c1"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "auto-finishes the stream when block completes" do
|
|
182
|
+
env = {}
|
|
183
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
184
|
+
|
|
185
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
186
|
+
s.task(status: { state: "TASK_STATE_WORKING" })
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
stream = env["a2a.stream"]
|
|
190
|
+
# After finish, read drains remaining data then returns nil
|
|
191
|
+
stream.read # the task event
|
|
192
|
+
stream.read.should.be.nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "auto-finishes even when block raises" do
|
|
196
|
+
env = {}
|
|
197
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
198
|
+
|
|
199
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
200
|
+
s.task(status: { state: "TASK_STATE_WORKING" })
|
|
201
|
+
raise "boom"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
stream = env["a2a.stream"]
|
|
205
|
+
stream.read # the task event
|
|
206
|
+
stream.read.should.be.nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it "returns nil" do
|
|
210
|
+
env = {}
|
|
211
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
212
|
+
|
|
213
|
+
result = builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
214
|
+
# no-op
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
result.should.be.nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "typed methods inject task_id and context_id" do
|
|
221
|
+
env = {}
|
|
222
|
+
builder = A2A::Middleware::StreamBuilder.new(env)
|
|
223
|
+
|
|
224
|
+
builder.open(task_id: "t1", context_id: "c1") do |s|
|
|
225
|
+
s.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
stream = env["a2a.stream"]
|
|
229
|
+
chunk = stream.read
|
|
230
|
+
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
231
|
+
parsed["statusUpdate"]["taskId"].should == "t1"
|
|
232
|
+
parsed["statusUpdate"]["contextId"].should == "c1"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "middleware/fetch_task"
|
|
4
|
+
require_relative "middleware/limit_history_length"
|
|
5
|
+
require_relative "middleware/limit_pagination_size"
|
|
6
|
+
require_relative "middleware/extract_message"
|
|
7
|
+
require_relative "middleware/sse_stream"
|
|
@@ -27,6 +27,7 @@ module A2A
|
|
|
27
27
|
def initialize(hash = {})
|
|
28
28
|
props = self.class.schema_properties
|
|
29
29
|
snake = self.class.snake_to_camel_map
|
|
30
|
+
refs = self.class.property_refs
|
|
30
31
|
@data = {}
|
|
31
32
|
|
|
32
33
|
hash.each do |key, value|
|
|
@@ -36,7 +37,13 @@ module A2A
|
|
|
36
37
|
camel = snake[k] || k
|
|
37
38
|
|
|
38
39
|
if props.include?(camel)
|
|
39
|
-
@data[camel] = value.is_a?(Definition)
|
|
40
|
+
@data[camel] = if value.is_a?(Definition)
|
|
41
|
+
value.to_h
|
|
42
|
+
elsif (ref_info = refs[camel])
|
|
43
|
+
wrap_ref(value, ref_info)
|
|
44
|
+
else
|
|
45
|
+
value
|
|
46
|
+
end
|
|
40
47
|
end
|
|
41
48
|
end
|
|
42
49
|
end
|
|
@@ -59,6 +66,10 @@ module A2A
|
|
|
59
66
|
raise "A2A::Schema::Definition should NOT be instantiated directly"
|
|
60
67
|
end
|
|
61
68
|
|
|
69
|
+
def self.property_refs
|
|
70
|
+
raise "A2A::Schema::Definition should NOT be instantiated directly"
|
|
71
|
+
end
|
|
72
|
+
|
|
62
73
|
# --- validation ----------------------------------------------------
|
|
63
74
|
|
|
64
75
|
def valid?
|
|
@@ -94,6 +105,29 @@ module A2A
|
|
|
94
105
|
|
|
95
106
|
private
|
|
96
107
|
|
|
108
|
+
def wrap_ref(value, ref_info)
|
|
109
|
+
kind, title = ref_info
|
|
110
|
+
|
|
111
|
+
case kind
|
|
112
|
+
when :object
|
|
113
|
+
value.is_a?(Hash) ? A2A::Schema[title].new(value) : value
|
|
114
|
+
when :array
|
|
115
|
+
if value.is_a?(Array)
|
|
116
|
+
value.map { |el| el.is_a?(Hash) ? A2A::Schema[title].new(el) : el }
|
|
117
|
+
else
|
|
118
|
+
value
|
|
119
|
+
end
|
|
120
|
+
when :map
|
|
121
|
+
if value.is_a?(Hash)
|
|
122
|
+
value.transform_values { |v| v.is_a?(Hash) ? A2A::Schema[title].new(v) : v }
|
|
123
|
+
else
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
value
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
97
131
|
def deep_compact(obj)
|
|
98
132
|
case obj
|
|
99
133
|
when Hash
|
data/lib/a2a/schema.rb
CHANGED
|
@@ -157,6 +157,7 @@ module A2A
|
|
|
157
157
|
properties = raw_definition.fetch("properties", {})
|
|
158
158
|
camel_keys = properties.keys
|
|
159
159
|
snake_to_camel = build_snake_to_camel(camel_keys)
|
|
160
|
+
prop_refs = build_property_refs(properties)
|
|
160
161
|
|
|
161
162
|
reader_pairs = camel_keys.map { |ck| [camel_to_snake(ck).to_sym, ck] }
|
|
162
163
|
|
|
@@ -165,12 +166,14 @@ module A2A
|
|
|
165
166
|
@definition_name = definition_name
|
|
166
167
|
@schema_properties = camel_keys
|
|
167
168
|
@snake_to_camel = snake_to_camel
|
|
169
|
+
@property_refs = prop_refs
|
|
168
170
|
|
|
169
171
|
class << self
|
|
170
172
|
def schema = @schema
|
|
171
173
|
def definition_name = @definition_name
|
|
172
174
|
def schema_properties = @schema_properties
|
|
173
175
|
def snake_to_camel_map = @snake_to_camel
|
|
176
|
+
def property_refs = @property_refs
|
|
174
177
|
end
|
|
175
178
|
|
|
176
179
|
reader_pairs.each do |snake_sym, camel_key|
|
|
@@ -179,6 +182,51 @@ module A2A
|
|
|
179
182
|
end
|
|
180
183
|
end
|
|
181
184
|
|
|
185
|
+
# Inspect each property's raw schema for $ref pointers and build
|
|
186
|
+
# a map of { camelKey => [:kind, "Definition Title"] } so that
|
|
187
|
+
# Definition#initialize can auto-wrap nested Hashes.
|
|
188
|
+
#
|
|
189
|
+
# Three patterns:
|
|
190
|
+
# :object — direct $ref (e.g. task → Task)
|
|
191
|
+
# :array — items.$ref (e.g. artifacts → [Artifact, ...])
|
|
192
|
+
# :map — additionalProperties.$ref (e.g. securitySchemes → {k => SecurityScheme})
|
|
193
|
+
def build_property_refs(properties)
|
|
194
|
+
definitions = raw_schema.fetch("definitions", {})
|
|
195
|
+
refs = {}
|
|
196
|
+
|
|
197
|
+
properties.each do |camel_key, prop_schema|
|
|
198
|
+
if (ref = prop_schema["$ref"])
|
|
199
|
+
# Direct $ref — singular nested object
|
|
200
|
+
title = ref_title_for(ref)
|
|
201
|
+
if title && definitions.dig(title, "properties")
|
|
202
|
+
refs[camel_key] = [:object, title]
|
|
203
|
+
end
|
|
204
|
+
elsif prop_schema["type"] == "array" && (ref = prop_schema.dig("items", "$ref"))
|
|
205
|
+
# Array with $ref items
|
|
206
|
+
title = ref_title_for(ref)
|
|
207
|
+
if title && definitions.dig(title, "properties")
|
|
208
|
+
refs[camel_key] = [:array, title]
|
|
209
|
+
end
|
|
210
|
+
elsif prop_schema["type"] == "object" && (ref = prop_schema.dig("additionalProperties", "$ref"))
|
|
211
|
+
# Map with $ref additionalProperties
|
|
212
|
+
title = ref_title_for(ref)
|
|
213
|
+
if title && definitions.dig(title, "properties")
|
|
214
|
+
refs[camel_key] = [:map, title]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
refs
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Extract the definition title from an internal $ref pointer.
|
|
223
|
+
# e.g. "#/definitions/Task%20Status" => "Task Status"
|
|
224
|
+
def ref_title_for(ref)
|
|
225
|
+
return nil unless ref.start_with?("#/definitions/")
|
|
226
|
+
|
|
227
|
+
URI::DEFAULT_PARSER.unescape(ref.sub("#/definitions/", ""))
|
|
228
|
+
end
|
|
229
|
+
|
|
182
230
|
def build_snake_to_camel(camel_keys)
|
|
183
231
|
map = {}
|
|
184
232
|
camel_keys.each do |camel|
|
|
@@ -426,4 +474,82 @@ test do
|
|
|
426
474
|
instance.is_a?(A2A::Schema::Definition).should == true
|
|
427
475
|
end
|
|
428
476
|
end
|
|
477
|
+
|
|
478
|
+
it "auto-wraps nested $ref Hash into Definition (object pattern)" do
|
|
479
|
+
response = schema["Send Message Response"].new(
|
|
480
|
+
task: {
|
|
481
|
+
"id" => "task-123",
|
|
482
|
+
"contextId" => "ctx-456",
|
|
483
|
+
"status" => {
|
|
484
|
+
"state" => "TASK_STATE_SUBMITTED",
|
|
485
|
+
"timestamp" => "2025-01-01T00:00:00Z"
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
response.task.should.be.kind_of(A2A::Schema::Definition)
|
|
490
|
+
response.task.id.should == "task-123"
|
|
491
|
+
response.task.context_id.should == "ctx-456"
|
|
492
|
+
# Deeply nested: Task.status is also auto-wrapped
|
|
493
|
+
response.task.status.should.be.kind_of(A2A::Schema::Definition)
|
|
494
|
+
response.task.status.state.should == "TASK_STATE_SUBMITTED"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
it "auto-wraps nested $ref arrays into Definitions (array pattern)" do
|
|
498
|
+
task = schema["Task"].new(
|
|
499
|
+
id: "task-1",
|
|
500
|
+
context_id: "ctx-1",
|
|
501
|
+
history: [
|
|
502
|
+
{ "role" => "ROLE_USER", "messageId" => "msg-1", "parts" => [{ "text" => "Hi" }] },
|
|
503
|
+
{ "role" => "ROLE_AGENT", "messageId" => "msg-2", "parts" => [{ "text" => "Hello" }] }
|
|
504
|
+
]
|
|
505
|
+
)
|
|
506
|
+
task.history.should.be.kind_of(Array)
|
|
507
|
+
task.history.length.should == 2
|
|
508
|
+
task.history[0].should.be.kind_of(A2A::Schema::Definition)
|
|
509
|
+
task.history[0].role.should == "ROLE_USER"
|
|
510
|
+
task.history[1].role.should == "ROLE_AGENT"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
it "preserves to_h serialization after auto-wrapping" do
|
|
514
|
+
response = schema["Send Message Response"].new(
|
|
515
|
+
task: {
|
|
516
|
+
"id" => "task-123",
|
|
517
|
+
"contextId" => "ctx-456",
|
|
518
|
+
"status" => {
|
|
519
|
+
"state" => "TASK_STATE_SUBMITTED",
|
|
520
|
+
"timestamp" => "2025-01-01T00:00:00Z"
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
)
|
|
524
|
+
h = response.to_h
|
|
525
|
+
h["task"].should.be.kind_of(Hash)
|
|
526
|
+
h["task"]["id"].should == "task-123"
|
|
527
|
+
h["task"]["status"].should.be.kind_of(Hash)
|
|
528
|
+
h["task"]["status"]["state"].should == "TASK_STATE_SUBMITTED"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
it "skips wrapping for opaque types without properties (Struct, Timestamp)" do
|
|
532
|
+
task = schema["Task"].new(
|
|
533
|
+
id: "task-1",
|
|
534
|
+
context_id: "ctx-1",
|
|
535
|
+
metadata: { "foo" => "bar" }
|
|
536
|
+
)
|
|
537
|
+
# metadata refs Struct which has no properties — should stay a plain Hash
|
|
538
|
+
task.metadata.should.be.kind_of(Hash)
|
|
539
|
+
task.metadata["foo"].should == "bar"
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
it "does not re-wrap values that are already Definition instances" do
|
|
543
|
+
status = schema["Task Status"].new(
|
|
544
|
+
state: "TASK_STATE_SUBMITTED",
|
|
545
|
+
timestamp: "2025-01-01T00:00:00Z"
|
|
546
|
+
)
|
|
547
|
+
task = schema["Task"].new(
|
|
548
|
+
id: "task-1",
|
|
549
|
+
context_id: "ctx-1",
|
|
550
|
+
status: status
|
|
551
|
+
)
|
|
552
|
+
# Passing a Definition instance should serialize it (existing behavior)
|
|
553
|
+
task.to_h["status"]["state"].should == "TASK_STATE_SUBMITTED"
|
|
554
|
+
end
|
|
429
555
|
end
|
|
@@ -5,8 +5,9 @@ require "a2a"
|
|
|
5
5
|
require "a2a/sse"
|
|
6
6
|
|
|
7
7
|
module A2A
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
class Server
|
|
9
|
+
module Bindings
|
|
10
|
+
# Rack middleware implementing the A2A JSON-RPC 2.0 protocol binding.
|
|
10
11
|
#
|
|
11
12
|
# Strips the JSON-RPC envelope from the inbound request, setting
|
|
12
13
|
# env keys for the method name, request id, and parsed params.
|
|
@@ -47,11 +48,11 @@ module A2A
|
|
|
47
48
|
env["a2a.json_rpc_method"] = method
|
|
48
49
|
env["a2a.body"] = params
|
|
49
50
|
|
|
50
|
-
@app.call(env)
|
|
51
|
+
result = @app.call(env)
|
|
51
52
|
|
|
52
|
-
# Check if
|
|
53
|
-
if (
|
|
54
|
-
return error_response(id,
|
|
53
|
+
# Check if the result is an error object
|
|
54
|
+
if result.is_a?(A2A::Error)
|
|
55
|
+
return error_response(id, result.code, result.message, result.error_data)
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
# Check if handler set up a streaming response.
|
|
@@ -60,7 +61,6 @@ module A2A
|
|
|
60
61
|
return [200, A2A::SSE::Stream.headers, stream]
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
result = env["a2a.result"]
|
|
64
64
|
success_response(id, result)
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -79,12 +79,16 @@ module A2A
|
|
|
79
79
|
[JSON.generate(jsonrpc: "2.0", id: id, error: err)]]
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
|
+
end
|
|
82
83
|
end
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
test do
|
|
87
|
+
require "a2a/test_helpers"
|
|
88
|
+
|
|
86
89
|
server = A2A::Server.new(agent_card: { "name" => "Test" })
|
|
87
|
-
|
|
90
|
+
server.register(A2A::TestHelpers.stub_agent)
|
|
91
|
+
rack = Rack::MockRequest.new(server)
|
|
88
92
|
|
|
89
93
|
A2A::Proto.operations.each do |op|
|
|
90
94
|
it "#{op.json_rpc_method} returns valid #{op.response_type}" do
|
|
@@ -5,8 +5,9 @@ require "a2a"
|
|
|
5
5
|
require "a2a/sse"
|
|
6
6
|
|
|
7
7
|
module A2A
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
class Server
|
|
9
|
+
module Bindings
|
|
10
|
+
# Rack middleware implementing the A2A HTTP+JSON/REST protocol binding.
|
|
10
11
|
#
|
|
11
12
|
# Extracts the HTTP verb, path, and request body/params into env keys.
|
|
12
13
|
# Calls downstream. On return, wraps env["a2a.result"] into a REST
|
|
@@ -40,11 +41,11 @@ module A2A
|
|
|
40
41
|
|
|
41
42
|
env["a2a.body"] = params
|
|
42
43
|
|
|
43
|
-
@app.call(env)
|
|
44
|
+
result = @app.call(env)
|
|
44
45
|
|
|
45
|
-
# Check if
|
|
46
|
-
if (
|
|
47
|
-
return error_response(
|
|
46
|
+
# Check if the result is an error object
|
|
47
|
+
if result.is_a?(A2A::Error)
|
|
48
|
+
return error_response(result.http_status, result.message, result.error_data)
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
# Check if handler set up a streaming response.
|
|
@@ -53,7 +54,6 @@ module A2A
|
|
|
53
54
|
return [200, A2A::SSE::Stream.headers, stream]
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
result = env["a2a.result"]
|
|
57
57
|
success_response(result)
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -72,12 +72,16 @@ module A2A
|
|
|
72
72
|
[JSON.generate(body)]]
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
|
+
end
|
|
75
76
|
end
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
test do
|
|
80
|
+
require "a2a/test_helpers"
|
|
81
|
+
|
|
79
82
|
server = A2A::Server.new(agent_card: { "name" => "Test" })
|
|
80
|
-
|
|
83
|
+
server.register(A2A::TestHelpers.stub_agent)
|
|
84
|
+
rack = Rack::MockRequest.new(server)
|
|
81
85
|
|
|
82
86
|
A2A::Proto.operations.each do |op|
|
|
83
87
|
it "#{op.rest_verb.upcase} #{op.rest_path} returns valid #{op.response_type}" do
|