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,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module A2A
|
|
9
|
+
module Faraday
|
|
10
|
+
module Middleware
|
|
11
|
+
module JsonRpc
|
|
12
|
+
# Faraday response middleware that unwraps JSON-RPC 2.0 responses
|
|
13
|
+
# and converts the result into A2A::Schema::Definition objects.
|
|
14
|
+
#
|
|
15
|
+
# Reads env.request.context[:a2a_operation] to determine which
|
|
16
|
+
# Schema class to instantiate. If no operation is set, passes through.
|
|
17
|
+
#
|
|
18
|
+
# Raises on JSON-RPC error responses.
|
|
19
|
+
#
|
|
20
|
+
class Response < ::Faraday::Middleware
|
|
21
|
+
def on_complete(env)
|
|
22
|
+
operation = env.request.context&.dig(:a2a_operation)
|
|
23
|
+
return unless operation
|
|
24
|
+
|
|
25
|
+
parsed = env.body
|
|
26
|
+
return if parsed.nil?
|
|
27
|
+
|
|
28
|
+
# Handle string bodies (if JSON response middleware hasn't run)
|
|
29
|
+
if parsed.is_a?(String)
|
|
30
|
+
return if parsed.empty?
|
|
31
|
+
parsed = JSON.parse(parsed)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Handle JSON-RPC error responses
|
|
35
|
+
if parsed.is_a?(Hash) && (err = parsed["error"])
|
|
36
|
+
raise A2A::JsonRpcError.new(err["message"], code: err["code"], data: err["data"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Extract result from JSON-RPC envelope
|
|
40
|
+
result = parsed.is_a?(Hash) && parsed.key?("result") ? parsed["result"] : parsed
|
|
41
|
+
|
|
42
|
+
schema_class = operation.response_schema
|
|
43
|
+
unless schema_class
|
|
44
|
+
env.body = result
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
env.body = schema_class.new(result)
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
# Leave body as-is if JSON parsing fails
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
::Faraday::Response.register_middleware(a2a_json_rpc: A2A::Faraday::Middleware::JsonRpc::Response)
|
|
59
|
+
|
|
60
|
+
test do
|
|
61
|
+
middleware = A2A::Faraday::Middleware::JsonRpc::Response
|
|
62
|
+
operation = A2A::Proto.operation("GetTask")
|
|
63
|
+
|
|
64
|
+
it "unwraps JSON-RPC result into Schema object" do
|
|
65
|
+
env = ::Faraday::Env.new
|
|
66
|
+
env.body = {
|
|
67
|
+
"jsonrpc" => "2.0", "id" => 1,
|
|
68
|
+
"result" => {
|
|
69
|
+
"id" => "task-123", "contextId" => "ctx-456",
|
|
70
|
+
"status" => { "state" => "TASK_STATE_SUBMITTED" }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
env.request = ::Faraday::RequestOptions.new
|
|
74
|
+
env.request.context = { a2a_operation: operation }
|
|
75
|
+
|
|
76
|
+
middleware.new(nil).on_complete(env)
|
|
77
|
+
|
|
78
|
+
env.body.should.be.kind_of(A2A::Schema::Definition)
|
|
79
|
+
env.body.id.should == "task-123"
|
|
80
|
+
env.body.context_id.should == "ctx-456"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "raises on JSON-RPC error" do
|
|
84
|
+
env = ::Faraday::Env.new
|
|
85
|
+
env.body = {
|
|
86
|
+
"jsonrpc" => "2.0", "id" => 1,
|
|
87
|
+
"error" => { "code" => -32600, "message" => "Invalid Request" }
|
|
88
|
+
}
|
|
89
|
+
env.request = ::Faraday::RequestOptions.new
|
|
90
|
+
env.request.context = { a2a_operation: operation }
|
|
91
|
+
|
|
92
|
+
lambda { middleware.new(nil).on_complete(env) }.should.raise(A2A::JsonRpcError)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "returns raw hash when response_schema is nil" do
|
|
96
|
+
op = A2A::Proto.operation("DeleteTaskPushNotificationConfig")
|
|
97
|
+
env = ::Faraday::Env.new
|
|
98
|
+
env.body = { "jsonrpc" => "2.0", "id" => 1, "result" => {} }
|
|
99
|
+
env.request = ::Faraday::RequestOptions.new
|
|
100
|
+
env.request.context = { a2a_operation: op }
|
|
101
|
+
|
|
102
|
+
middleware.new(nil).on_complete(env)
|
|
103
|
+
|
|
104
|
+
env.body.should == {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "passes through when no operation is set" do
|
|
108
|
+
env = ::Faraday::Env.new
|
|
109
|
+
env.body = { "foo" => "bar" }
|
|
110
|
+
env.request = ::Faraday::RequestOptions.new
|
|
111
|
+
|
|
112
|
+
middleware.new(nil).on_complete(env)
|
|
113
|
+
|
|
114
|
+
env.body.should == { "foo" => "bar" }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "handles string body" do
|
|
118
|
+
env = ::Faraday::Env.new
|
|
119
|
+
env.body = JSON.generate({
|
|
120
|
+
"jsonrpc" => "2.0", "id" => 1,
|
|
121
|
+
"result" => { "id" => "task-1", "contextId" => "ctx-1", "status" => { "state" => "TASK_STATE_COMPLETED" } }
|
|
122
|
+
})
|
|
123
|
+
env.request = ::Faraday::RequestOptions.new
|
|
124
|
+
env.request.context = { a2a_operation: operation }
|
|
125
|
+
|
|
126
|
+
middleware.new(nil).on_complete(env)
|
|
127
|
+
|
|
128
|
+
env.body.should.be.kind_of(A2A::Schema::Definition)
|
|
129
|
+
env.body.id.should == "task-1"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
require "faraday"
|
|
6
|
+
|
|
7
|
+
module A2A
|
|
8
|
+
module Faraday
|
|
9
|
+
module Middleware
|
|
10
|
+
module REST
|
|
11
|
+
# Faraday request middleware that rewrites the request for the
|
|
12
|
+
# A2A HTTP+JSON/REST protocol binding.
|
|
13
|
+
#
|
|
14
|
+
# Reads env.request.context[:a2a_operation] to determine the
|
|
15
|
+
# HTTP verb and path. Interpolates path parameters from the
|
|
16
|
+
# request body (e.g. {id=*} placeholders). For GET/DELETE
|
|
17
|
+
# operations, remaining params become query string parameters.
|
|
18
|
+
#
|
|
19
|
+
# Sets Content-Type to application/a2a+json for POST requests.
|
|
20
|
+
#
|
|
21
|
+
class Request < ::Faraday::Middleware
|
|
22
|
+
def on_request(env)
|
|
23
|
+
operation = env.request.context&.dig(:a2a_operation)
|
|
24
|
+
return unless operation
|
|
25
|
+
|
|
26
|
+
params = env.body || {}
|
|
27
|
+
path = interpolate_path(operation.rest_path, params)
|
|
28
|
+
remaining = remove_path_params(operation.rest_path, params)
|
|
29
|
+
|
|
30
|
+
env.url.path = path
|
|
31
|
+
env.method = operation.rest_verb.to_sym
|
|
32
|
+
|
|
33
|
+
if [:get, :delete].include?(env.method)
|
|
34
|
+
remaining.each { |k, v| env.params[k.to_s] = v }
|
|
35
|
+
env.body = nil
|
|
36
|
+
else
|
|
37
|
+
env.body = remaining
|
|
38
|
+
env.request_headers["content-type"] = "application/a2a+json"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Extract {name=*} placeholder names from a path pattern.
|
|
45
|
+
def path_param_names(pattern)
|
|
46
|
+
pattern.scan(/\{(\w+)(?:=[^}]*)?\}/).flatten
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Substitute {name=*} placeholders with values from params.
|
|
50
|
+
# Pattern names are snake_case (e.g. task_id) but Schema#to_h
|
|
51
|
+
# produces camelCase keys (e.g. taskId), so we check both.
|
|
52
|
+
def interpolate_path(pattern, params)
|
|
53
|
+
pattern.gsub(/\{(\w+)(?:=[^}]*)?\}/) do
|
|
54
|
+
name = $1
|
|
55
|
+
camel = snake_to_camel(name)
|
|
56
|
+
params[name] || params[name.to_sym] ||
|
|
57
|
+
params[camel] || params[camel.to_sym] || ""
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Return params with path-interpolated keys removed.
|
|
62
|
+
def remove_path_params(pattern, params)
|
|
63
|
+
names = path_param_names(pattern)
|
|
64
|
+
camel_names = names.map { |n| snake_to_camel(n) }
|
|
65
|
+
all_names = (names + camel_names).to_a
|
|
66
|
+
params.reject { |k, _| all_names.include?(k.to_s) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# "task_id" => "taskId"
|
|
70
|
+
def snake_to_camel(str)
|
|
71
|
+
str.gsub(/_([a-z])/) { $1.upcase }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
::Faraday::Request.register_middleware(a2a_rest: A2A::Faraday::Middleware::REST::Request)
|
|
80
|
+
|
|
81
|
+
test do
|
|
82
|
+
middleware = A2A::Faraday::Middleware::REST::Request
|
|
83
|
+
|
|
84
|
+
it "rewrites POST operation with correct path, verb, and content-type" do
|
|
85
|
+
operation = A2A::Proto.operation("SendMessage")
|
|
86
|
+
env = ::Faraday::Env.new
|
|
87
|
+
env.url = URI.parse("http://localhost:9292/")
|
|
88
|
+
env.body = { "message" => { "role" => "ROLE_USER" } }
|
|
89
|
+
env.params = {}
|
|
90
|
+
env.request_headers = {}
|
|
91
|
+
env.request = ::Faraday::RequestOptions.new
|
|
92
|
+
env.request.context = { a2a_operation: operation }
|
|
93
|
+
|
|
94
|
+
middleware.new(nil).on_request(env)
|
|
95
|
+
|
|
96
|
+
env.url.path.should == "/message:send"
|
|
97
|
+
env.method.should == :post
|
|
98
|
+
env.body.should == { "message" => { "role" => "ROLE_USER" } }
|
|
99
|
+
env.request_headers["content-type"].should == "application/a2a+json"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "rewrites GET operation with path interpolation and query params" do
|
|
103
|
+
operation = A2A::Proto.operation("GetTask")
|
|
104
|
+
env = ::Faraday::Env.new
|
|
105
|
+
env.url = URI.parse("http://localhost:9292/")
|
|
106
|
+
env.body = { "id" => "task-123", "historyLength" => "5" }
|
|
107
|
+
env.params = {}
|
|
108
|
+
env.request_headers = {}
|
|
109
|
+
env.request = ::Faraday::RequestOptions.new
|
|
110
|
+
env.request.context = { a2a_operation: operation }
|
|
111
|
+
|
|
112
|
+
middleware.new(nil).on_request(env)
|
|
113
|
+
|
|
114
|
+
env.url.path.should == "/tasks/task-123"
|
|
115
|
+
env.method.should == :get
|
|
116
|
+
env.body.should.be.nil
|
|
117
|
+
env.params["historyLength"].should == "5"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "rewrites DELETE operation with multi-param path interpolation" do
|
|
121
|
+
operation = A2A::Proto.operation("DeleteTaskPushNotificationConfig")
|
|
122
|
+
env = ::Faraday::Env.new
|
|
123
|
+
env.url = URI.parse("http://localhost:9292/")
|
|
124
|
+
env.body = { "taskId" => "task-123", "id" => "config-1" }
|
|
125
|
+
env.params = {}
|
|
126
|
+
env.request_headers = {}
|
|
127
|
+
env.request = ::Faraday::RequestOptions.new
|
|
128
|
+
env.request.context = { a2a_operation: operation }
|
|
129
|
+
|
|
130
|
+
middleware.new(nil).on_request(env)
|
|
131
|
+
|
|
132
|
+
env.url.path.should == "/tasks/task-123/pushNotificationConfigs/config-1"
|
|
133
|
+
env.method.should == :delete
|
|
134
|
+
env.body.should.be.nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "rewrites POST cancel with path interpolation" do
|
|
138
|
+
operation = A2A::Proto.operation("CancelTask")
|
|
139
|
+
env = ::Faraday::Env.new
|
|
140
|
+
env.url = URI.parse("http://localhost:9292/")
|
|
141
|
+
env.body = { "id" => "task-123", "metadata" => { "key" => "val" } }
|
|
142
|
+
env.params = {}
|
|
143
|
+
env.request_headers = {}
|
|
144
|
+
env.request = ::Faraday::RequestOptions.new
|
|
145
|
+
env.request.context = { a2a_operation: operation }
|
|
146
|
+
|
|
147
|
+
middleware.new(nil).on_request(env)
|
|
148
|
+
|
|
149
|
+
env.url.path.should == "/tasks/task-123:cancel"
|
|
150
|
+
env.method.should == :post
|
|
151
|
+
env.body.should == { "metadata" => { "key" => "val" } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "passes through when no operation is set" do
|
|
155
|
+
env = ::Faraday::Env.new
|
|
156
|
+
env.url = URI.parse("http://localhost:9292/")
|
|
157
|
+
env.body = { "foo" => "bar" }
|
|
158
|
+
env.params = {}
|
|
159
|
+
env.request_headers = {}
|
|
160
|
+
env.request = ::Faraday::RequestOptions.new
|
|
161
|
+
|
|
162
|
+
middleware.new(nil).on_request(env)
|
|
163
|
+
|
|
164
|
+
env.body.should == { "foo" => "bar" }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module A2A
|
|
9
|
+
module Faraday
|
|
10
|
+
module Middleware
|
|
11
|
+
module REST
|
|
12
|
+
# Faraday response middleware for the A2A HTTP+JSON/REST binding.
|
|
13
|
+
#
|
|
14
|
+
# Unlike the JSON-RPC binding, REST responses have no envelope —
|
|
15
|
+
# the body IS the result directly. Errors are signaled via HTTP
|
|
16
|
+
# status codes with application/problem+json bodies.
|
|
17
|
+
#
|
|
18
|
+
# Reads env.request.context[:a2a_operation] to determine which
|
|
19
|
+
# Schema class to instantiate. If no operation is set, passes through.
|
|
20
|
+
#
|
|
21
|
+
class Response < ::Faraday::Middleware
|
|
22
|
+
def on_complete(env)
|
|
23
|
+
operation = env.request.context&.dig(:a2a_operation)
|
|
24
|
+
return unless operation
|
|
25
|
+
|
|
26
|
+
if env.status >= 400
|
|
27
|
+
parsed = env.body
|
|
28
|
+
if parsed.is_a?(Hash)
|
|
29
|
+
message = parsed["title"] || parsed["message"] || parsed.to_s
|
|
30
|
+
data = parsed["detail"]
|
|
31
|
+
else
|
|
32
|
+
message = parsed.to_s
|
|
33
|
+
data = nil
|
|
34
|
+
end
|
|
35
|
+
raise A2A::RestError.new(message, http_status: env.status, data: data)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
parsed = env.body
|
|
39
|
+
return if parsed.nil?
|
|
40
|
+
|
|
41
|
+
if parsed.is_a?(String)
|
|
42
|
+
return if parsed.empty?
|
|
43
|
+
parsed = JSON.parse(parsed)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
schema_class = operation.response_schema
|
|
47
|
+
unless schema_class
|
|
48
|
+
env.body = parsed
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
env.body = schema_class.new(parsed)
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
# Leave body as-is if JSON parsing fails
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
::Faraday::Response.register_middleware(a2a_rest: A2A::Faraday::Middleware::REST::Response)
|
|
63
|
+
|
|
64
|
+
test do
|
|
65
|
+
middleware = A2A::Faraday::Middleware::REST::Response
|
|
66
|
+
operation = A2A::Proto.operation("GetTask")
|
|
67
|
+
|
|
68
|
+
it "wraps response body in Schema object directly (no envelope)" do
|
|
69
|
+
env = ::Faraday::Env.new
|
|
70
|
+
env.status = 200
|
|
71
|
+
env.body = {
|
|
72
|
+
"id" => "task-123", "contextId" => "ctx-456",
|
|
73
|
+
"status" => { "state" => "TASK_STATE_SUBMITTED" }
|
|
74
|
+
}
|
|
75
|
+
env.request = ::Faraday::RequestOptions.new
|
|
76
|
+
env.request.context = { a2a_operation: operation }
|
|
77
|
+
|
|
78
|
+
middleware.new(nil).on_complete(env)
|
|
79
|
+
|
|
80
|
+
env.body.should.be.kind_of(A2A::Schema::Definition)
|
|
81
|
+
env.body.id.should == "task-123"
|
|
82
|
+
env.body.context_id.should == "ctx-456"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "returns raw hash when response_schema is nil" do
|
|
86
|
+
op = A2A::Proto.operation("DeleteTaskPushNotificationConfig")
|
|
87
|
+
env = ::Faraday::Env.new
|
|
88
|
+
env.status = 200
|
|
89
|
+
env.body = {}
|
|
90
|
+
env.request = ::Faraday::RequestOptions.new
|
|
91
|
+
env.request.context = { a2a_operation: op }
|
|
92
|
+
|
|
93
|
+
middleware.new(nil).on_complete(env)
|
|
94
|
+
|
|
95
|
+
env.body.should == {}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises on HTTP 4xx error" do
|
|
99
|
+
env = ::Faraday::Env.new
|
|
100
|
+
env.status = 400
|
|
101
|
+
env.body = { "type" => "error", "title" => "Bad Request", "status" => 400 }
|
|
102
|
+
env.request = ::Faraday::RequestOptions.new
|
|
103
|
+
env.request.context = { a2a_operation: operation }
|
|
104
|
+
|
|
105
|
+
lambda { middleware.new(nil).on_complete(env) }.should.raise(A2A::RestError)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "raises on HTTP 5xx error" do
|
|
109
|
+
env = ::Faraday::Env.new
|
|
110
|
+
env.status = 500
|
|
111
|
+
env.body = { "type" => "error", "title" => "Internal Server Error", "status" => 500 }
|
|
112
|
+
env.request = ::Faraday::RequestOptions.new
|
|
113
|
+
env.request.context = { a2a_operation: operation }
|
|
114
|
+
|
|
115
|
+
lambda { middleware.new(nil).on_complete(env) }.should.raise(A2A::RestError)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "passes through when no operation is set" do
|
|
119
|
+
env = ::Faraday::Env.new
|
|
120
|
+
env.status = 200
|
|
121
|
+
env.body = { "foo" => "bar" }
|
|
122
|
+
env.request = ::Faraday::RequestOptions.new
|
|
123
|
+
|
|
124
|
+
middleware.new(nil).on_complete(env)
|
|
125
|
+
|
|
126
|
+
env.body.should == { "foo" => "bar" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "handles string body" do
|
|
130
|
+
env = ::Faraday::Env.new
|
|
131
|
+
env.status = 200
|
|
132
|
+
env.body = JSON.generate({
|
|
133
|
+
"id" => "task-1", "contextId" => "ctx-1",
|
|
134
|
+
"status" => { "state" => "TASK_STATE_COMPLETED" }
|
|
135
|
+
})
|
|
136
|
+
env.request = ::Faraday::RequestOptions.new
|
|
137
|
+
env.request.context = { a2a_operation: operation }
|
|
138
|
+
|
|
139
|
+
middleware.new(nil).on_complete(env)
|
|
140
|
+
|
|
141
|
+
env.body.should.be.kind_of(A2A::Schema::Definition)
|
|
142
|
+
env.body.id.should == "task-1"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module A2A
|
|
9
|
+
module Faraday
|
|
10
|
+
module Middleware
|
|
11
|
+
# Faraday request middleware that converts A2A::Schema::Definition
|
|
12
|
+
# objects into their hash representation for JSON serialization.
|
|
13
|
+
#
|
|
14
|
+
# Place this before the :json request middleware in the stack so
|
|
15
|
+
# that Schema bodies are converted to hashes before Faraday's
|
|
16
|
+
# JSON middleware serializes them to strings.
|
|
17
|
+
#
|
|
18
|
+
class SchemaRequest < ::Faraday::Middleware
|
|
19
|
+
def on_request(env)
|
|
20
|
+
body = env.body
|
|
21
|
+
return unless body.is_a?(A2A::Schema::Definition)
|
|
22
|
+
|
|
23
|
+
env.body = body.to_h
|
|
24
|
+
env.request_headers["content-type"] ||= "application/json"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
::Faraday::Request.register_middleware(a2a_schema: A2A::Faraday::Middleware::SchemaRequest)
|
|
32
|
+
|
|
33
|
+
test do
|
|
34
|
+
middleware = A2A::Faraday::Middleware::SchemaRequest
|
|
35
|
+
|
|
36
|
+
it "converts Schema::Definition to hash" do
|
|
37
|
+
schema_obj = A2A::Schema["Agent Capabilities"].new(streaming: true)
|
|
38
|
+
env = ::Faraday::Env.new
|
|
39
|
+
env.body = schema_obj
|
|
40
|
+
env.request_headers = {}
|
|
41
|
+
|
|
42
|
+
middleware.new(nil).on_request(env)
|
|
43
|
+
|
|
44
|
+
env.body.should == { "streaming" => true }
|
|
45
|
+
env.request_headers["content-type"].should == "application/json"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "does not modify non-Schema bodies" do
|
|
49
|
+
env = ::Faraday::Env.new
|
|
50
|
+
env.body = { "foo" => "bar" }
|
|
51
|
+
env.request_headers = {}
|
|
52
|
+
|
|
53
|
+
middleware.new(nil).on_request(env)
|
|
54
|
+
|
|
55
|
+
env.body.should == { "foo" => "bar" }
|
|
56
|
+
env.request_headers["content-type"].should.be.nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "does not overwrite existing content-type" do
|
|
60
|
+
schema_obj = A2A::Schema["Agent Capabilities"].new(streaming: true)
|
|
61
|
+
env = ::Faraday::Env.new
|
|
62
|
+
env.body = schema_obj
|
|
63
|
+
env.request_headers = { "content-type" => "application/a2a+json" }
|
|
64
|
+
|
|
65
|
+
middleware.new(nil).on_request(env)
|
|
66
|
+
|
|
67
|
+
env.request_headers["content-type"].should == "application/a2a+json"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "a2a"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Middleware
|
|
8
|
+
# Extracts text from the request message's parts and sets
|
|
9
|
+
# `env["a2a.message"]` to the joined text string.
|
|
10
|
+
#
|
|
11
|
+
# Replaces the common `extract_text` lambda pattern found in examples:
|
|
12
|
+
#
|
|
13
|
+
# extract_text = ->(message) {
|
|
14
|
+
# parts = message.parts || []
|
|
15
|
+
# parts.filter_map { |p| p.text }.join("\n")
|
|
16
|
+
# }
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
#
|
|
20
|
+
# on "SendMessage" do
|
|
21
|
+
# use A2A::Middleware::ExtractMessage
|
|
22
|
+
# respond_with -> (env) {
|
|
23
|
+
# text = env["a2a.message"]
|
|
24
|
+
# # ...
|
|
25
|
+
# }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
class ExtractMessage
|
|
29
|
+
def initialize(app)
|
|
30
|
+
@app = app
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(env)
|
|
34
|
+
request = env["a2a.request"]
|
|
35
|
+
message = request.message
|
|
36
|
+
parts = message.parts || []
|
|
37
|
+
|
|
38
|
+
env["a2a.message"] = parts.filter_map { |p|
|
|
39
|
+
p.respond_to?(:text) ? p.text : p["text"]
|
|
40
|
+
}.join("\n")
|
|
41
|
+
|
|
42
|
+
@app.call(env)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
test do
|
|
49
|
+
describe "A2A::Middleware::ExtractMessage" do
|
|
50
|
+
it "extracts text from message parts" do
|
|
51
|
+
part1 = Object.new
|
|
52
|
+
part1.define_singleton_method(:text) { "hello" }
|
|
53
|
+
part2 = Object.new
|
|
54
|
+
part2.define_singleton_method(:text) { "world" }
|
|
55
|
+
|
|
56
|
+
message = Object.new
|
|
57
|
+
message.define_singleton_method(:parts) { [part1, part2] }
|
|
58
|
+
|
|
59
|
+
request = Object.new
|
|
60
|
+
request.define_singleton_method(:message) { message }
|
|
61
|
+
|
|
62
|
+
downstream = -> (env) { env["a2a.message"] }
|
|
63
|
+
mw = A2A::Middleware::ExtractMessage.new(downstream)
|
|
64
|
+
env = { "a2a.request" => request }
|
|
65
|
+
|
|
66
|
+
result = mw.call(env)
|
|
67
|
+
result.should == "hello\nworld"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "handles nil parts" do
|
|
71
|
+
message = Object.new
|
|
72
|
+
message.define_singleton_method(:parts) { nil }
|
|
73
|
+
|
|
74
|
+
request = Object.new
|
|
75
|
+
request.define_singleton_method(:message) { message }
|
|
76
|
+
|
|
77
|
+
downstream = -> (env) { env["a2a.message"] }
|
|
78
|
+
mw = A2A::Middleware::ExtractMessage.new(downstream)
|
|
79
|
+
env = { "a2a.request" => request }
|
|
80
|
+
|
|
81
|
+
result = mw.call(env)
|
|
82
|
+
result.should == ""
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "skips parts without text" do
|
|
86
|
+
part1 = Object.new
|
|
87
|
+
part1.define_singleton_method(:text) { "hello" }
|
|
88
|
+
part2 = Object.new
|
|
89
|
+
part2.define_singleton_method(:text) { nil }
|
|
90
|
+
|
|
91
|
+
message = Object.new
|
|
92
|
+
message.define_singleton_method(:parts) { [part1, part2] }
|
|
93
|
+
|
|
94
|
+
request = Object.new
|
|
95
|
+
request.define_singleton_method(:message) { message }
|
|
96
|
+
|
|
97
|
+
downstream = -> (env) { env["a2a.message"] }
|
|
98
|
+
mw = A2A::Middleware::ExtractMessage.new(downstream)
|
|
99
|
+
env = { "a2a.request" => request }
|
|
100
|
+
|
|
101
|
+
result = mw.call(env)
|
|
102
|
+
result.should == "hello"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "handles hash-style parts" do
|
|
106
|
+
message = Object.new
|
|
107
|
+
message.define_singleton_method(:parts) { [{ "text" => "from hash" }] }
|
|
108
|
+
|
|
109
|
+
request = Object.new
|
|
110
|
+
request.define_singleton_method(:message) { message }
|
|
111
|
+
|
|
112
|
+
downstream = -> (env) { env["a2a.message"] }
|
|
113
|
+
mw = A2A::Middleware::ExtractMessage.new(downstream)
|
|
114
|
+
env = { "a2a.request" => request }
|
|
115
|
+
|
|
116
|
+
result = mw.call(env)
|
|
117
|
+
result.should == "from hash"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|