agent2agent 1.1.0 → 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 +30 -20
- 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 -40
- 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
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "a2a"
|
|
5
|
-
|
|
6
|
-
module A2A
|
|
7
|
-
module Schema
|
|
8
|
-
# Raised when a Definition instance fails schema validation.
|
|
9
|
-
#
|
|
10
|
-
# Collects JSONSchemer error details and formats them as a
|
|
11
|
-
# human-readable list with dot-notation field paths.
|
|
12
|
-
#
|
|
13
|
-
# Agent Card validation failed:
|
|
14
|
-
# - name is required but missing
|
|
15
|
-
# - capabilities.streaming must be boolean, got string
|
|
16
|
-
#
|
|
17
|
-
class ValidationError < StandardError
|
|
18
|
-
attr_reader :errors, :definition_name, :data
|
|
19
|
-
|
|
20
|
-
def initialize(errors, definition_name:, data: nil)
|
|
21
|
-
@errors = errors
|
|
22
|
-
@definition_name = definition_name
|
|
23
|
-
@data = data
|
|
24
|
-
|
|
25
|
-
super(build_message)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def build_message
|
|
31
|
-
lines = errors.map { |e| " - #{format_error(e)}" }
|
|
32
|
-
"#{definition_name} validation failed:\n#{lines.join("\n")}"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def format_error(error)
|
|
36
|
-
path = format_path(error)
|
|
37
|
-
type = error["type"]
|
|
38
|
-
|
|
39
|
-
case type
|
|
40
|
-
when "required"
|
|
41
|
-
missing = error.dig("details", "missing_keys")&.join(", ") || "unknown"
|
|
42
|
-
if path.empty?
|
|
43
|
-
"#{missing} is required but missing"
|
|
44
|
-
else
|
|
45
|
-
"#{path}.#{missing} is required but missing"
|
|
46
|
-
end
|
|
47
|
-
when "type"
|
|
48
|
-
expected = Array(error.dig("schema", "type")).join(" or ")
|
|
49
|
-
"#{path.empty? ? "(root)" : path} must be #{expected}"
|
|
50
|
-
when "enum"
|
|
51
|
-
allowed = error.dig("schema", "enum")&.join(", ") || "?"
|
|
52
|
-
"#{path.empty? ? "(root)" : path} must be one of: #{allowed}"
|
|
53
|
-
when "pattern"
|
|
54
|
-
pattern = error.dig("schema", "pattern")
|
|
55
|
-
"#{path.empty? ? "(root)" : path} must match pattern #{pattern}"
|
|
56
|
-
when "format"
|
|
57
|
-
fmt = error.dig("schema", "format")
|
|
58
|
-
"#{path.empty? ? "(root)" : path} must be a valid #{fmt}"
|
|
59
|
-
when "minimum", "maximum"
|
|
60
|
-
"#{path.empty? ? "(root)" : path} #{error["error"]}"
|
|
61
|
-
when "additionalProperties"
|
|
62
|
-
"#{path.empty? ? "(root)" : path} has unknown properties"
|
|
63
|
-
else
|
|
64
|
-
detail = error["error"] || error["type"] || "invalid"
|
|
65
|
-
"#{path.empty? ? "(root)" : path} #{detail}"
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Convert JSON pointer like "/properties/capabilities/streaming"
|
|
70
|
-
# to dot notation like "capabilities.streaming"
|
|
71
|
-
def format_path(error)
|
|
72
|
-
pointer = error["data_pointer"].to_s
|
|
73
|
-
return "" if pointer.empty? || pointer == "/"
|
|
74
|
-
|
|
75
|
-
pointer.delete_prefix("/").gsub("/", ".")
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
test do
|
|
82
|
-
error_data = [
|
|
83
|
-
{
|
|
84
|
-
"data_pointer" => "",
|
|
85
|
-
"type" => "required",
|
|
86
|
-
"details" => { "missing_keys" => ["name"] },
|
|
87
|
-
"schema" => {},
|
|
88
|
-
"error" => "missing keys: name"
|
|
89
|
-
}
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
err = A2A::Schema::ValidationError.new(error_data, definition_name: "Agent Card")
|
|
93
|
-
|
|
94
|
-
it "includes the definition name" do
|
|
95
|
-
err.message.should.include?("Agent Card")
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
it "formats required errors" do
|
|
99
|
-
err.message.should.include?("name is required but missing")
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
it "stores the errors array" do
|
|
103
|
-
err.errors.should == error_data
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
it "stores the definition name" do
|
|
107
|
-
err.definition_name.should == "Agent Card"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
nested = A2A::Schema::ValidationError.new(
|
|
111
|
-
[{ "data_pointer" => "/capabilities/streaming", "type" => "type",
|
|
112
|
-
"schema" => { "type" => "boolean" }, "error" => "wrong type" }],
|
|
113
|
-
definition_name: "Agent Card"
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
it "formats nested paths with dot notation" do
|
|
117
|
-
nested.message.should.include?("capabilities.streaming must be boolean")
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
additional = A2A::Schema::ValidationError.new(
|
|
121
|
-
[{ "data_pointer" => "/foo", "type" => "additionalProperties",
|
|
122
|
-
"schema" => {}, "error" => "unexpected" }],
|
|
123
|
-
definition_name: "Test"
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
it "formats additionalProperties errors" do
|
|
127
|
-
additional.message.should.include?("foo has unknown properties")
|
|
128
|
-
end
|
|
129
|
-
end
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "console"
|
|
5
|
-
require "a2a"
|
|
6
|
-
|
|
7
|
-
module A2A
|
|
8
|
-
class Server
|
|
9
|
-
# Routes incoming A2A operations to their registered handler stacks.
|
|
10
|
-
#
|
|
11
|
-
# Each operation maps to exactly one Rack app (a compiled middleware
|
|
12
|
-
# stack built by the Agent DSL). The Dispatcher is a terminal Rack app
|
|
13
|
-
# that reads env["a2a.operation"] set by Triage, looks up the matching
|
|
14
|
-
# route, and calls it.
|
|
15
|
-
#
|
|
16
|
-
# Returns domain objects (A2A::Schema::Definition or A2A::Error) —
|
|
17
|
-
# the binding layer is responsible for formatting these into HTTP.
|
|
18
|
-
#
|
|
19
|
-
class Dispatcher
|
|
20
|
-
def initialize
|
|
21
|
-
@routes = {}
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Register a handler object.
|
|
25
|
-
#
|
|
26
|
-
# The handler must respond to:
|
|
27
|
-
# #operations -> Array(String) (e.g. ["SendMessage", "GetTask"])
|
|
28
|
-
# #call(env) -> A2A::Schema::Definition | A2A::Error
|
|
29
|
-
#
|
|
30
|
-
def register(handler)
|
|
31
|
-
handler.operations.each do |op|
|
|
32
|
-
@routes[op] = handler
|
|
33
|
-
Console.info(self) { "Registered #{handler.class.name} for #{op}" }
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def call(env)
|
|
38
|
-
operation = env["a2a.operation"]
|
|
39
|
-
app = @routes[operation]
|
|
40
|
-
|
|
41
|
-
unless app
|
|
42
|
-
raise A2A::UnsupportedOperationError.new(
|
|
43
|
-
message: "Operation not supported: #{operation}"
|
|
44
|
-
)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
app.call(env)
|
|
48
|
-
rescue A2A::Error => e
|
|
49
|
-
e
|
|
50
|
-
rescue => e
|
|
51
|
-
Console.error(self, "Handler raised #{e.class}: #{e.message}", e)
|
|
52
|
-
A2A::Error.new("Internal error", code: -32603, http_status: 500)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def handler_count
|
|
56
|
-
@routes.size
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
test do
|
|
63
|
-
describe "A2A::Server::Dispatcher" do
|
|
64
|
-
it "registers and dispatches to a handler" do
|
|
65
|
-
handler = Object.new
|
|
66
|
-
handler.define_singleton_method(:operations) { ["SendMessage"] }
|
|
67
|
-
handler.define_singleton_method(:call) { |env| :dispatched }
|
|
68
|
-
|
|
69
|
-
dispatcher = A2A::Server::Dispatcher.new
|
|
70
|
-
dispatcher.register(handler)
|
|
71
|
-
dispatcher.handler_count.should == 1
|
|
72
|
-
|
|
73
|
-
env = { "a2a.operation" => "SendMessage" }
|
|
74
|
-
result = dispatcher.call(env)
|
|
75
|
-
result.should == :dispatched
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
it "returns UnsupportedOperationError for unknown operations" do
|
|
79
|
-
dispatcher = A2A::Server::Dispatcher.new
|
|
80
|
-
env = { "a2a.operation" => "UnknownOp" }
|
|
81
|
-
result = dispatcher.call(env)
|
|
82
|
-
result.should.be.is_a(A2A::UnsupportedOperationError)
|
|
83
|
-
result.code.should == -32004
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
it "returns A2A::Error when handler raises one" do
|
|
87
|
-
handler = Object.new
|
|
88
|
-
handler.define_singleton_method(:operations) { ["SendMessage"] }
|
|
89
|
-
handler.define_singleton_method(:call) { |_| raise A2A::TaskNotFoundError.new("t-1") }
|
|
90
|
-
|
|
91
|
-
dispatcher = A2A::Server::Dispatcher.new
|
|
92
|
-
dispatcher.register(handler)
|
|
93
|
-
|
|
94
|
-
result = dispatcher.call({ "a2a.operation" => "SendMessage" })
|
|
95
|
-
result.should.be.is_a(A2A::TaskNotFoundError)
|
|
96
|
-
result.code.should == -32001
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
it "returns internal error when handler raises unexpected exception" do
|
|
100
|
-
handler = Object.new
|
|
101
|
-
handler.define_singleton_method(:operations) { ["SendMessage"] }
|
|
102
|
-
handler.define_singleton_method(:call) { |_| raise "boom" }
|
|
103
|
-
|
|
104
|
-
dispatcher = A2A::Server::Dispatcher.new
|
|
105
|
-
dispatcher.register(handler)
|
|
106
|
-
|
|
107
|
-
result = dispatcher.call({ "a2a.operation" => "SendMessage" })
|
|
108
|
-
result.should.be.is_a(A2A::Error)
|
|
109
|
-
result.code.should == -32603
|
|
110
|
-
result.http_status.should == 500
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
it "dispatches to multiple operations from one handler" do
|
|
114
|
-
received = []
|
|
115
|
-
handler = Object.new
|
|
116
|
-
handler.define_singleton_method(:operations) { ["SendMessage", "GetTask"] }
|
|
117
|
-
handler.define_singleton_method(:call) { |env| received << env["a2a.operation"]; :ok }
|
|
118
|
-
|
|
119
|
-
dispatcher = A2A::Server::Dispatcher.new
|
|
120
|
-
dispatcher.register(handler)
|
|
121
|
-
|
|
122
|
-
dispatcher.call({ "a2a.operation" => "SendMessage" })
|
|
123
|
-
dispatcher.call({ "a2a.operation" => "GetTask" })
|
|
124
|
-
received.should == ["SendMessage", "GetTask"]
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "stream"
|
|
4
|
-
|
|
5
|
-
module A2A
|
|
6
|
-
module SSE
|
|
7
|
-
# SSE stream that wraps each event in a JSON-RPC 2.0 response envelope.
|
|
8
|
-
#
|
|
9
|
-
# Per the A2A spec, JSON-RPC streaming returns SSE where each `data:` line
|
|
10
|
-
# is a full JSON-RPC response: {"jsonrpc":"2.0","id":N,"result":{...}}
|
|
11
|
-
#
|
|
12
|
-
# Usage:
|
|
13
|
-
#
|
|
14
|
-
# stream = A2A::SSE::JsonRpcStream.new(
|
|
15
|
-
# task_id: "t1", context_id: "c1", json_rpc_id: 1
|
|
16
|
-
# )
|
|
17
|
-
#
|
|
18
|
-
# Async do
|
|
19
|
-
# stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
|
|
20
|
-
# stream.finish
|
|
21
|
-
# end
|
|
22
|
-
#
|
|
23
|
-
# [200, A2A::SSE::Stream.headers, stream]
|
|
24
|
-
#
|
|
25
|
-
class JsonRpcStream < Stream
|
|
26
|
-
def initialize(json_rpc_id:, **options)
|
|
27
|
-
@json_rpc_id = json_rpc_id
|
|
28
|
-
super(**options)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Emit an SSE event wrapped in a JSON-RPC envelope.
|
|
32
|
-
#
|
|
33
|
-
# @param data [Hash] the StreamResponse payload (becomes "result")
|
|
34
|
-
#
|
|
35
|
-
def event(data, **opts)
|
|
36
|
-
envelope = {
|
|
37
|
-
"jsonrpc" => "2.0",
|
|
38
|
-
"id" => @json_rpc_id,
|
|
39
|
-
"result" => data.respond_to?(:to_h) ? data.to_h : data,
|
|
40
|
-
}
|
|
41
|
-
super(envelope, **opts)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
test do
|
|
48
|
-
describe "A2A::SSE::JsonRpcStream" do
|
|
49
|
-
it "wraps events in JSON-RPC 2.0 envelopes" do
|
|
50
|
-
stream = A2A::SSE::JsonRpcStream.new(
|
|
51
|
-
task_id: "t1", context_id: "c1", json_rpc_id: 42
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "2025-01-01T00:00:00Z" })
|
|
55
|
-
stream.finish
|
|
56
|
-
|
|
57
|
-
chunk = stream.read
|
|
58
|
-
chunk.should.include('"jsonrpc"')
|
|
59
|
-
chunk.should.include('"2.0"')
|
|
60
|
-
chunk.should.include('"id":42') # numeric id preserved
|
|
61
|
-
chunk.should.include('"result"')
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
it "is a subclass of SSE::Stream" do
|
|
65
|
-
stream = A2A::SSE::JsonRpcStream.new(
|
|
66
|
-
task_id: "t1", context_id: "c1", json_rpc_id: 1
|
|
67
|
-
)
|
|
68
|
-
stream.is_a?(A2A::SSE::Stream).should == true
|
|
69
|
-
stream.is_a?(Protocol::HTTP::Body::Readable).should == true
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
it "typed methods chain through JSON-RPC envelope" do
|
|
73
|
-
stream = A2A::SSE::JsonRpcStream.new(
|
|
74
|
-
task_id: "t1", context_id: "c1", json_rpc_id: 7
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
78
|
-
stream.finish
|
|
79
|
-
|
|
80
|
-
chunk = stream.read
|
|
81
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
82
|
-
parsed["jsonrpc"].should == "2.0"
|
|
83
|
-
parsed["id"].should == 7
|
|
84
|
-
parsed["result"]["statusUpdate"]["taskId"].should == "t1"
|
|
85
|
-
parsed["result"]["statusUpdate"]["contextId"].should == "c1"
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
data/lib/a2a/sse/rest_stream.rb
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "stream"
|
|
4
|
-
|
|
5
|
-
module A2A
|
|
6
|
-
module SSE
|
|
7
|
-
# SSE stream for the HTTP+JSON/REST binding.
|
|
8
|
-
#
|
|
9
|
-
# Per the A2A spec, REST streaming returns SSE where each `data:` line
|
|
10
|
-
# is a bare StreamResponse JSON object (no envelope wrapping).
|
|
11
|
-
#
|
|
12
|
-
# This is essentially just an alias for Stream — the base class already
|
|
13
|
-
# emits bare JSON. We keep this as a named class for symmetry with
|
|
14
|
-
# JsonRpcStream and to make binding code self-documenting.
|
|
15
|
-
#
|
|
16
|
-
# Usage:
|
|
17
|
-
#
|
|
18
|
-
# stream = A2A::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
19
|
-
#
|
|
20
|
-
# Async do
|
|
21
|
-
# stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
|
|
22
|
-
# stream.finish
|
|
23
|
-
# end
|
|
24
|
-
#
|
|
25
|
-
# [200, A2A::SSE::Stream.headers, stream]
|
|
26
|
-
#
|
|
27
|
-
class RestStream < Stream
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
test do
|
|
33
|
-
describe "A2A::SSE::RestStream" do
|
|
34
|
-
it "emits bare JSON events (no envelope)" do
|
|
35
|
-
stream = A2A::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
36
|
-
|
|
37
|
-
stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
38
|
-
stream.finish
|
|
39
|
-
|
|
40
|
-
chunk = stream.read
|
|
41
|
-
chunk.should.include('"statusUpdate"')
|
|
42
|
-
chunk.should.not.include('"jsonrpc"')
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
it "is a subclass of SSE::Stream" do
|
|
46
|
-
stream = A2A::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
47
|
-
stream.is_a?(A2A::SSE::Stream).should == true
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
it "injects task_id and context_id into typed events" do
|
|
51
|
-
stream = A2A::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
52
|
-
|
|
53
|
-
stream.task(status: { state: "TASK_STATE_WORKING" })
|
|
54
|
-
stream.finish
|
|
55
|
-
|
|
56
|
-
chunk = stream.read
|
|
57
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
58
|
-
parsed["task"]["id"].should == "t1"
|
|
59
|
-
parsed["task"]["contextId"].should == "c1"
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
data/lib/a2a/sse/stream.rb
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "protocol/http/body/writable"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module A2A
|
|
7
|
-
module SSE
|
|
8
|
-
# Async-native SSE body built on Protocol::HTTP::Body::Writable.
|
|
9
|
-
#
|
|
10
|
-
# Falcon's protocol-rack passes Protocol::HTTP::Body::Readable subclasses
|
|
11
|
-
# through untouched — no Enumerable wrapping, no intermediate buffering.
|
|
12
|
-
# This gives us true async streaming with backpressure for free.
|
|
13
|
-
#
|
|
14
|
-
# The gospel (protocol-http) teaches:
|
|
15
|
-
# - Writable is a producer/consumer queue body
|
|
16
|
-
# - write() pushes chunks; read() pops them (blocks when empty)
|
|
17
|
-
# - close_write signals EOF (reader gets nil)
|
|
18
|
-
# - Client disconnect raises on next write()
|
|
19
|
-
#
|
|
20
|
-
# Usage:
|
|
21
|
-
#
|
|
22
|
-
# stream = A2A::SSE::RestStream.new(task_id: "t1", context_id: "c1")
|
|
23
|
-
#
|
|
24
|
-
# Async do
|
|
25
|
-
# stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "..." })
|
|
26
|
-
# stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "..." })
|
|
27
|
-
# stream.finish
|
|
28
|
-
# end
|
|
29
|
-
#
|
|
30
|
-
# # Return as Rack body — Falcon streams it natively
|
|
31
|
-
# [200, { "content-type" => "text/event-stream" }, stream]
|
|
32
|
-
#
|
|
33
|
-
class Stream < Protocol::HTTP::Body::Writable
|
|
34
|
-
SSE_HEADERS = {
|
|
35
|
-
"content-type" => "text/event-stream",
|
|
36
|
-
"cache-control" => "no-cache, no-transform",
|
|
37
|
-
"x-accel-buffering" => "no",
|
|
38
|
-
"connection" => "keep-alive",
|
|
39
|
-
}.freeze
|
|
40
|
-
|
|
41
|
-
attr_reader :task_id, :context_id
|
|
42
|
-
|
|
43
|
-
def initialize(task_id:, context_id:, **options)
|
|
44
|
-
@task_id = task_id
|
|
45
|
-
@context_id = context_id
|
|
46
|
-
super(**options)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Emit an SSE event.
|
|
50
|
-
#
|
|
51
|
-
# @param data [Hash, String] the event payload (Hashes are JSON-encoded)
|
|
52
|
-
# @param type [String, nil] optional SSE event type field
|
|
53
|
-
# @param id [String, nil] optional SSE event id field
|
|
54
|
-
#
|
|
55
|
-
def event(data, type: nil, id: nil)
|
|
56
|
-
payload = data.is_a?(String) ? data : JSON.generate(data)
|
|
57
|
-
|
|
58
|
-
buf = String.new
|
|
59
|
-
buf << "event: #{type}\n" if type
|
|
60
|
-
buf << "id: #{id}\n" if id
|
|
61
|
-
|
|
62
|
-
# SSE spec: each line of data gets its own `data:` prefix.
|
|
63
|
-
# For single-line JSON this is one line; multi-line is handled correctly.
|
|
64
|
-
payload.each_line do |line|
|
|
65
|
-
buf << "data: #{line.chomp}\n"
|
|
66
|
-
end
|
|
67
|
-
buf << "\n" # blank line terminates the event
|
|
68
|
-
|
|
69
|
-
write(buf)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Signal end-of-stream.
|
|
73
|
-
# The reader will receive nil on next read(), closing the SSE connection.
|
|
74
|
-
def finish
|
|
75
|
-
close_write
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Convenience: the SSE headers to return in the Rack response.
|
|
79
|
-
def self.headers
|
|
80
|
-
SSE_HEADERS
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# --- Typed event emitters -------------------------------------------
|
|
84
|
-
#
|
|
85
|
-
# Dynamically generated from the StreamResponse schema.
|
|
86
|
-
# Each property in StreamResponse (task, message, statusUpdate,
|
|
87
|
-
# artifactUpdate) gets a snake_case method that:
|
|
88
|
-
#
|
|
89
|
-
# 1. Injects @task_id and @context_id (using the correct key name
|
|
90
|
-
# for each type — Task uses `id`, others use `task_id`)
|
|
91
|
-
# 2. Builds the schema Definition from kwargs
|
|
92
|
-
# 3. Wraps it in the StreamResponse envelope
|
|
93
|
-
# 4. Calls #event to emit the SSE wire format
|
|
94
|
-
#
|
|
95
|
-
# This means you never pass task_id/context_id manually:
|
|
96
|
-
#
|
|
97
|
-
# stream.task(status: { state: "TASK_STATE_WORKING" })
|
|
98
|
-
# stream.artifact_update(artifact: { ... }, append: false, last_chunk: true)
|
|
99
|
-
# stream.status_update(status: { state: "TASK_STATE_COMPLETED" })
|
|
100
|
-
# stream.message(role: "ROLE_AGENT", parts: [{ text: "Hello" }])
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
stream_response = A2A::Schema["Stream Response"]
|
|
104
|
-
|
|
105
|
-
stream_response.property_refs.each do |camel_key, (_kind, title)|
|
|
106
|
-
target_props = A2A::Schema[title].schema_properties
|
|
107
|
-
|
|
108
|
-
# Task uses `id` for the task identifier; the other three use `taskId`
|
|
109
|
-
task_id_key = target_props.include?("id") ? :id : :task_id
|
|
110
|
-
|
|
111
|
-
# camelCase -> snake_case method name
|
|
112
|
-
method_name = camel_key
|
|
113
|
-
.gsub(/([A-Z])/) { "_#{$1.downcase}" }
|
|
114
|
-
.delete_prefix("_")
|
|
115
|
-
|
|
116
|
-
define_method(method_name) do |**kwargs|
|
|
117
|
-
merged = { task_id_key => @task_id, context_id: @context_id }.merge(kwargs)
|
|
118
|
-
event({ camel_key => A2A::Schema[title].new(merged).to_h })
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
test do
|
|
126
|
-
require "async"
|
|
127
|
-
|
|
128
|
-
describe "A2A::SSE::Stream" do
|
|
129
|
-
it "formats SSE events correctly" do
|
|
130
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
131
|
-
|
|
132
|
-
stream.event({ "task" => { "id" => "t1" } })
|
|
133
|
-
stream.finish
|
|
134
|
-
|
|
135
|
-
chunk = stream.read
|
|
136
|
-
chunk.should.include("data: ")
|
|
137
|
-
chunk.should.include('"task"')
|
|
138
|
-
chunk.should.end_with("\n\n")
|
|
139
|
-
|
|
140
|
-
# After finish, read returns nil
|
|
141
|
-
stream.read.should.be.nil
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
it "includes event type when provided" do
|
|
145
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
146
|
-
|
|
147
|
-
stream.event("hello", type: "ping")
|
|
148
|
-
stream.finish
|
|
149
|
-
|
|
150
|
-
chunk = stream.read
|
|
151
|
-
chunk.should.include("event: ping\n")
|
|
152
|
-
chunk.should.include("data: hello\n")
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
it "includes event id when provided" do
|
|
156
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
157
|
-
|
|
158
|
-
stream.event("test", id: "42")
|
|
159
|
-
stream.finish
|
|
160
|
-
|
|
161
|
-
chunk = stream.read
|
|
162
|
-
chunk.should.include("id: 42\n")
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
it "is a Protocol::HTTP::Body::Readable" do
|
|
166
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
167
|
-
stream.is_a?(Protocol::HTTP::Body::Readable).should == true
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
it "provides standard SSE headers" do
|
|
171
|
-
headers = A2A::SSE::Stream.headers
|
|
172
|
-
headers["content-type"].should == "text/event-stream"
|
|
173
|
-
headers["cache-control"].should.include("no-cache")
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
it "stores task_id and context_id" do
|
|
177
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
178
|
-
stream.task_id.should == "t1"
|
|
179
|
-
stream.context_id.should == "c1"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# --- Typed emit methods ---
|
|
183
|
-
|
|
184
|
-
it "#task emits a task event with injected id and context_id" do
|
|
185
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
186
|
-
|
|
187
|
-
stream.task(status: { state: "TASK_STATE_WORKING", timestamp: "2025-01-01T00:00:00Z" })
|
|
188
|
-
stream.finish
|
|
189
|
-
|
|
190
|
-
chunk = stream.read
|
|
191
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
192
|
-
parsed["task"]["id"].should == "t1"
|
|
193
|
-
parsed["task"]["contextId"].should == "c1"
|
|
194
|
-
parsed["task"]["status"]["state"].should == "TASK_STATE_WORKING"
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
it "#status_update emits with injected task_id and context_id" do
|
|
198
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
199
|
-
|
|
200
|
-
stream.status_update(status: { state: "TASK_STATE_COMPLETED", timestamp: "2025-01-01T00:00:00Z" })
|
|
201
|
-
stream.finish
|
|
202
|
-
|
|
203
|
-
chunk = stream.read
|
|
204
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
205
|
-
parsed["statusUpdate"]["taskId"].should == "t1"
|
|
206
|
-
parsed["statusUpdate"]["contextId"].should == "c1"
|
|
207
|
-
parsed["statusUpdate"]["status"]["state"].should == "TASK_STATE_COMPLETED"
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
it "#artifact_update emits with injected task_id and context_id" do
|
|
211
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
212
|
-
|
|
213
|
-
stream.artifact_update(
|
|
214
|
-
artifact: { artifact_id: "a1", parts: [{ text: "hello" }] },
|
|
215
|
-
append: false,
|
|
216
|
-
last_chunk: true
|
|
217
|
-
)
|
|
218
|
-
stream.finish
|
|
219
|
-
|
|
220
|
-
chunk = stream.read
|
|
221
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
222
|
-
parsed["artifactUpdate"]["taskId"].should == "t1"
|
|
223
|
-
parsed["artifactUpdate"]["contextId"].should == "c1"
|
|
224
|
-
parsed["artifactUpdate"]["artifact"]["artifactId"].should == "a1"
|
|
225
|
-
parsed["artifactUpdate"]["append"].should == false
|
|
226
|
-
parsed["artifactUpdate"]["lastChunk"].should == true
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
it "#message emits with injected task_id and context_id" do
|
|
230
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
231
|
-
|
|
232
|
-
stream.message(
|
|
233
|
-
message_id: "m1",
|
|
234
|
-
role: "ROLE_AGENT",
|
|
235
|
-
parts: [{ text: "Hello" }]
|
|
236
|
-
)
|
|
237
|
-
stream.finish
|
|
238
|
-
|
|
239
|
-
chunk = stream.read
|
|
240
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
241
|
-
parsed["message"]["taskId"].should == "t1"
|
|
242
|
-
parsed["message"]["contextId"].should == "c1"
|
|
243
|
-
parsed["message"]["role"].should == "ROLE_AGENT"
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
it "typed methods allow overriding injected values" do
|
|
247
|
-
stream = A2A::SSE::Stream.new(task_id: "t1", context_id: "c1")
|
|
248
|
-
|
|
249
|
-
stream.task(id: "override", status: { state: "TASK_STATE_WORKING" })
|
|
250
|
-
stream.finish
|
|
251
|
-
|
|
252
|
-
chunk = stream.read
|
|
253
|
-
parsed = JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
254
|
-
parsed["task"]["id"].should == "override"
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../../../../a2a/server/dispatcher"
|
|
4
|
-
require "traces/provider"
|
|
5
|
-
|
|
6
|
-
Traces::Provider(A2A::Server::Dispatcher) do
|
|
7
|
-
def call(env)
|
|
8
|
-
attributes = {
|
|
9
|
-
"a2a.operation" => env["a2a.operation"],
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
Traces.trace("a2a.server.dispatcher.call", attributes: attributes) do |span|
|
|
13
|
-
super.tap do |status, headers, body|
|
|
14
|
-
span["http.status_code"] = status
|
|
15
|
-
|
|
16
|
-
if env["a2a.stream"]
|
|
17
|
-
span["a2a.response_type"] = "stream"
|
|
18
|
-
elsif env["a2a.error"]
|
|
19
|
-
span["a2a.response_type"] = "error"
|
|
20
|
-
elsif env["a2a.result"]
|
|
21
|
-
span["a2a.response_type"] = "result"
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
File without changes
|
|
File without changes
|