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,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Protocol
|
|
8
|
+
module JsonSchema
|
|
9
|
+
# Base class for schema-validated A2A protocol objects.
|
|
10
|
+
#
|
|
11
|
+
# Each A2A definition type (Agent Card, Agent Capabilities, Task, etc.)
|
|
12
|
+
# gets a dynamically-generated subclass of Definition with:
|
|
13
|
+
# - A JSONSchemer sub-schema attached (.schema)
|
|
14
|
+
# - Reader methods for each property (snake_case)
|
|
15
|
+
# - Validation via .valid? / .valid!
|
|
16
|
+
#
|
|
17
|
+
# caps = A2A::Protocol::JsonSchema["Agent Capabilities"].new(
|
|
18
|
+
# streaming: true,
|
|
19
|
+
# push_notifications: false
|
|
20
|
+
# )
|
|
21
|
+
# caps.valid? #=> true
|
|
22
|
+
# caps.streaming #=> true
|
|
23
|
+
# caps.push_notifications #=> false
|
|
24
|
+
# caps.to_h #=> { "streaming" => true, "pushNotifications" => false }
|
|
25
|
+
#
|
|
26
|
+
class Definition
|
|
27
|
+
|
|
28
|
+
def initialize(hash = {})
|
|
29
|
+
props = self.class.schema_properties
|
|
30
|
+
snake = self.class.snake_to_camel_map
|
|
31
|
+
refs = self.class.property_refs
|
|
32
|
+
@data = {}
|
|
33
|
+
|
|
34
|
+
hash.each do |key, value|
|
|
35
|
+
k = key.to_s
|
|
36
|
+
|
|
37
|
+
# Resolve snake_case input to camelCase storage key
|
|
38
|
+
camel = snake[k] || k
|
|
39
|
+
|
|
40
|
+
if props.include?(camel)
|
|
41
|
+
@data[camel] = if value.is_a?(Definition)
|
|
42
|
+
value.to_h
|
|
43
|
+
elsif (ref_info = refs[camel])
|
|
44
|
+
wrap_ref(value, ref_info)
|
|
45
|
+
else
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- class methods overridden by the factory -----------------------
|
|
53
|
+
|
|
54
|
+
def self.schema
|
|
55
|
+
raise "A2A::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.definition_name
|
|
59
|
+
raise "A2A::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.schema_properties
|
|
63
|
+
raise "A2A::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.snake_to_camel_map
|
|
67
|
+
raise "A2A::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.property_refs
|
|
71
|
+
raise "A2A::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reverse of snake_to_camel_map. Built first-entry-wins: the
|
|
75
|
+
# factory inserts the snake_case key before the camelCase
|
|
76
|
+
# identity key, so camelCase storage keys map back to their
|
|
77
|
+
# snake_case reader names.
|
|
78
|
+
def self.camel_to_snake_map
|
|
79
|
+
@camel_to_snake_map ||= snake_to_camel_map.each_with_object({}) do |(key, camel), map|
|
|
80
|
+
map[camel] ||= key
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# --- validation ----------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def valid?
|
|
87
|
+
self.class.schema.valid?(to_h)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def valid!
|
|
91
|
+
errors = self.class.schema.validate(to_h).to_a
|
|
92
|
+
return true if errors.empty?
|
|
93
|
+
|
|
94
|
+
raise ValidationError.new(errors,
|
|
95
|
+
definition_name: self.class.definition_name,
|
|
96
|
+
data: to_h
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# --- serialization -------------------------------------------------
|
|
101
|
+
|
|
102
|
+
# Returns the data as a plain Hash with camelCase string keys,
|
|
103
|
+
# matching the JSON wire format. Nested Definition instances
|
|
104
|
+
# are auto-coerced via deep_compact.
|
|
105
|
+
def to_h
|
|
106
|
+
deep_compact(@data)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ==(other)
|
|
110
|
+
other.is_a?(Definition) && to_h == other.to_h
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- pattern matching ------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# Supports Ruby hash pattern matching:
|
|
116
|
+
#
|
|
117
|
+
# case env["a2a.request"].message
|
|
118
|
+
# in { task_id: String => id, parts: }
|
|
119
|
+
# ...
|
|
120
|
+
# end
|
|
121
|
+
#
|
|
122
|
+
# Keys are the snake_case property names, same as the reader
|
|
123
|
+
# methods (camelCase symbols also work). Absent properties are
|
|
124
|
+
# omitted from the result so patterns that require them fail to
|
|
125
|
+
# match. Nested Definition values are returned as-is, so
|
|
126
|
+
# patterns can destructure recursively.
|
|
127
|
+
def deconstruct_keys(keys)
|
|
128
|
+
snake = self.class.snake_to_camel_map
|
|
129
|
+
|
|
130
|
+
if keys
|
|
131
|
+
keys.each_with_object({}) do |key, result|
|
|
132
|
+
camel = snake[key.to_s] || key.to_s
|
|
133
|
+
result[key] = @data[camel] if @data.key?(camel)
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
camel_to_snake = self.class.camel_to_snake_map
|
|
137
|
+
@data.each_with_object({}) do |(camel, value), result|
|
|
138
|
+
result[(camel_to_snake[camel] || camel).to_sym] = value
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def inspect
|
|
144
|
+
"#<#{self.class.definition_name} #{to_h.inspect}>"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def wrap_ref(value, ref_info)
|
|
150
|
+
kind, title = ref_info
|
|
151
|
+
|
|
152
|
+
case kind
|
|
153
|
+
when :object
|
|
154
|
+
value.is_a?(Hash) ? A2A::Protocol::JsonSchema[title].new(value) : value
|
|
155
|
+
when :array
|
|
156
|
+
if value.is_a?(Array)
|
|
157
|
+
value.map { |el| el.is_a?(Hash) ? A2A::Protocol::JsonSchema[title].new(el) : el }
|
|
158
|
+
else
|
|
159
|
+
value
|
|
160
|
+
end
|
|
161
|
+
when :map
|
|
162
|
+
if value.is_a?(Hash)
|
|
163
|
+
value.transform_values { |v| v.is_a?(Hash) ? A2A::Protocol::JsonSchema[title].new(v) : v }
|
|
164
|
+
else
|
|
165
|
+
value
|
|
166
|
+
end
|
|
167
|
+
else
|
|
168
|
+
value
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def deep_compact(obj)
|
|
173
|
+
case obj
|
|
174
|
+
when Hash
|
|
175
|
+
obj.each_with_object({}) do |(k, v), result|
|
|
176
|
+
compacted = deep_compact(v)
|
|
177
|
+
result[k] = compacted unless compacted.nil?
|
|
178
|
+
end
|
|
179
|
+
when Array
|
|
180
|
+
obj.map { |v| deep_compact(v) }
|
|
181
|
+
when Definition
|
|
182
|
+
obj.to_h
|
|
183
|
+
else
|
|
184
|
+
obj
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
__END__
|
|
193
|
+
describe "A2A::Protocol::JsonSchema::Definition#deconstruct_keys" do
|
|
194
|
+
schema = A2A::Protocol::JsonSchema
|
|
195
|
+
|
|
196
|
+
it "matches snake_case keys" do
|
|
197
|
+
msg = schema["Message"].new(role: "ROLE_USER", message_id: "m-1", task_id: "t-1")
|
|
198
|
+
|
|
199
|
+
case msg
|
|
200
|
+
in { task_id: String => id, role: }
|
|
201
|
+
id.should == "t-1"
|
|
202
|
+
role.should == "ROLE_USER"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "matches camelCase keys" do
|
|
207
|
+
msg = schema["Message"].new(role: "ROLE_USER", task_id: "t-1")
|
|
208
|
+
|
|
209
|
+
case msg
|
|
210
|
+
in { taskId: String => id }
|
|
211
|
+
id.should == "t-1"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "omits absent properties so patterns requiring them fail" do
|
|
216
|
+
msg = schema["Message"].new(role: "ROLE_USER")
|
|
217
|
+
|
|
218
|
+
matched = case msg
|
|
219
|
+
in { task_id: String }
|
|
220
|
+
true
|
|
221
|
+
else
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
matched.should == false
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "omits absent properties rather than yielding nil" do
|
|
229
|
+
msg = schema["Message"].new(role: "ROLE_USER")
|
|
230
|
+
msg.deconstruct_keys([:task_id]).key?(:task_id).should == false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "destructures nested Definitions recursively" do
|
|
234
|
+
task = schema["Task"].new(
|
|
235
|
+
id: "t-1",
|
|
236
|
+
context_id: "c-1",
|
|
237
|
+
status: { "state" => "TASK_STATE_WORKING", "timestamp" => "2025-01-01T00:00:00.000Z" },
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
case task
|
|
241
|
+
in { status: { state: } }
|
|
242
|
+
state.should == "TASK_STATE_WORKING"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it "destructures Definitions inside arrays" do
|
|
247
|
+
msg = schema["Message"].new(
|
|
248
|
+
role: "ROLE_USER",
|
|
249
|
+
message_id: "m-1",
|
|
250
|
+
parts: [{ "text" => "hello" }],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
case msg
|
|
254
|
+
in { parts: [{ text: }] }
|
|
255
|
+
text.should == "hello"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "returns all properties as snake_case symbols for a nil key filter" do
|
|
260
|
+
msg = schema["Message"].new(role: "ROLE_USER", message_id: "m-1")
|
|
261
|
+
msg.deconstruct_keys(nil).should == { role: "ROLE_USER", message_id: "m-1" }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it "supports guarded operation-style matching" do
|
|
265
|
+
msg = schema["Message"].new(role: "ROLE_USER", message_id: "m-1", task_id: "")
|
|
266
|
+
|
|
267
|
+
matched = case msg
|
|
268
|
+
in { task_id: String => id } if !id.empty?
|
|
269
|
+
:continuation
|
|
270
|
+
else
|
|
271
|
+
:new_task
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
matched.should == :new_task
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Protocol
|
|
8
|
+
module JsonSchema
|
|
9
|
+
# Raised when a Definition instance fails schema validation.
|
|
10
|
+
#
|
|
11
|
+
# Collects JSONSchemer error details and formats them as a
|
|
12
|
+
# human-readable list with dot-notation field paths.
|
|
13
|
+
#
|
|
14
|
+
# Agent Card validation failed:
|
|
15
|
+
# - name is required but missing
|
|
16
|
+
# - capabilities.streaming must be boolean, got string
|
|
17
|
+
#
|
|
18
|
+
class ValidationError < StandardError
|
|
19
|
+
attr_reader :errors, :definition_name, :data
|
|
20
|
+
|
|
21
|
+
def initialize(errors, definition_name:, data: nil)
|
|
22
|
+
@errors = errors
|
|
23
|
+
@definition_name = definition_name
|
|
24
|
+
@data = data
|
|
25
|
+
|
|
26
|
+
super(build_message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_message
|
|
32
|
+
lines = errors.map { |e| " - #{format_error(e)}" }
|
|
33
|
+
"#{definition_name} validation failed:\n#{lines.join("\n")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_error(error)
|
|
37
|
+
path = format_path(error)
|
|
38
|
+
type = error["type"]
|
|
39
|
+
|
|
40
|
+
case type
|
|
41
|
+
when "required"
|
|
42
|
+
missing = error.dig("details", "missing_keys")&.join(", ") || "unknown"
|
|
43
|
+
if path.empty?
|
|
44
|
+
"#{missing} is required but missing"
|
|
45
|
+
else
|
|
46
|
+
"#{path}.#{missing} is required but missing"
|
|
47
|
+
end
|
|
48
|
+
when "type"
|
|
49
|
+
expected = Array(error.dig("schema", "type")).join(" or ")
|
|
50
|
+
"#{path.empty? ? "(root)" : path} must be #{expected}"
|
|
51
|
+
when "enum"
|
|
52
|
+
allowed = error.dig("schema", "enum")&.join(", ") || "?"
|
|
53
|
+
"#{path.empty? ? "(root)" : path} must be one of: #{allowed}"
|
|
54
|
+
when "pattern"
|
|
55
|
+
pattern = error.dig("schema", "pattern")
|
|
56
|
+
"#{path.empty? ? "(root)" : path} must match pattern #{pattern}"
|
|
57
|
+
when "format"
|
|
58
|
+
fmt = error.dig("schema", "format")
|
|
59
|
+
"#{path.empty? ? "(root)" : path} must be a valid #{fmt}"
|
|
60
|
+
when "minimum", "maximum"
|
|
61
|
+
"#{path.empty? ? "(root)" : path} #{error["error"]}"
|
|
62
|
+
when "additionalProperties"
|
|
63
|
+
"#{path.empty? ? "(root)" : path} has unknown properties"
|
|
64
|
+
else
|
|
65
|
+
detail = error["error"] || error["type"] || "invalid"
|
|
66
|
+
"#{path.empty? ? "(root)" : path} #{detail}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convert JSON pointer like "/properties/capabilities/streaming"
|
|
71
|
+
# to dot notation like "capabilities.streaming"
|
|
72
|
+
def format_path(error)
|
|
73
|
+
pointer = error["data_pointer"].to_s
|
|
74
|
+
return "" if pointer.empty? || pointer == "/"
|
|
75
|
+
|
|
76
|
+
pointer.delete_prefix("/").gsub("/", ".")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
__END__
|
|
84
|
+
describe "A2A::Protocol::JsonSchema::ValidationError" do
|
|
85
|
+
error_data = [
|
|
86
|
+
{
|
|
87
|
+
"data_pointer" => "",
|
|
88
|
+
"type" => "required",
|
|
89
|
+
"details" => { "missing_keys" => ["name"] },
|
|
90
|
+
"schema" => {},
|
|
91
|
+
"error" => "missing keys: name"
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
err = A2A::Protocol::JsonSchema::ValidationError.new(error_data, definition_name: "Agent Card")
|
|
96
|
+
|
|
97
|
+
it "includes the definition name" do
|
|
98
|
+
err.message.should.include?("Agent Card")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "formats required errors" do
|
|
102
|
+
err.message.should.include?("name is required but missing")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "stores the errors array" do
|
|
106
|
+
err.errors.should == error_data
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "stores the definition name" do
|
|
110
|
+
err.definition_name.should == "Agent Card"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
nested = A2A::Protocol::JsonSchema::ValidationError.new(
|
|
114
|
+
[{ "data_pointer" => "/capabilities/streaming", "type" => "type",
|
|
115
|
+
"schema" => { "type" => "boolean" }, "error" => "wrong type" }],
|
|
116
|
+
definition_name: "Agent Card"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
it "formats nested paths with dot notation" do
|
|
120
|
+
nested.message.should.include?("capabilities.streaming must be boolean")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
additional = A2A::Protocol::JsonSchema::ValidationError.new(
|
|
124
|
+
[{ "data_pointer" => "/foo", "type" => "additionalProperties",
|
|
125
|
+
"schema" => {}, "error" => "unexpected" }],
|
|
126
|
+
definition_name: "Test"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
it "formats additionalProperties errors" do
|
|
130
|
+
additional.message.should.include?("foo has unknown properties")
|
|
131
|
+
end
|
|
132
|
+
end
|