agent2agent 1.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/data/a2a.json +1961 -0
  3. data/data/a2a.proto +796 -0
  4. data/data/download-a2a-resources +4 -0
  5. data/data/spec.txt +3611 -0
  6. data/lib/a2a/agent.rb +243 -0
  7. data/lib/a2a/bindings/json_rpc.rb +110 -0
  8. data/lib/a2a/bindings/rest.rb +106 -0
  9. data/lib/a2a/client.rb +85 -0
  10. data/lib/a2a/proto.rb +444 -0
  11. data/lib/a2a/schema/definition.rb +114 -0
  12. data/lib/a2a/schema/validation_error.rb +129 -0
  13. data/lib/a2a/schema.rb +429 -0
  14. data/lib/a2a/server/cancel_task.rb +14 -0
  15. data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
  16. data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
  17. data/lib/a2a/server/dispatcher.rb +127 -0
  18. data/lib/a2a/server/env.rb +28 -0
  19. data/lib/a2a/server/get_extended_agent_card.rb +15 -0
  20. data/lib/a2a/server/get_task.rb +14 -0
  21. data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
  22. data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
  23. data/lib/a2a/server/list_tasks.rb +14 -0
  24. data/lib/a2a/server/send_message.rb +14 -0
  25. data/lib/a2a/server/send_streaming_message.rb +14 -0
  26. data/lib/a2a/server/subscribe_to_task.rb +14 -0
  27. data/lib/a2a/server/triage.rb +96 -0
  28. data/lib/a2a/server.rb +98 -0
  29. data/lib/a2a/sse/json_rpc_stream.rb +66 -0
  30. data/lib/a2a/sse/rest_stream.rb +50 -0
  31. data/lib/a2a/sse/stream.rb +129 -0
  32. data/lib/a2a/sse.rb +5 -0
  33. data/lib/a2a/store/processor.rb +136 -0
  34. data/lib/a2a/store/pub_sub.rb +149 -0
  35. data/lib/a2a/store/sqlite.rb +533 -0
  36. data/lib/a2a/store/webhooks.rb +94 -0
  37. data/lib/a2a/store.rb +6 -0
  38. data/lib/a2a/task_store.rb +315 -0
  39. data/lib/a2a/version.rb +5 -0
  40. data/lib/a2a.rb +26 -0
  41. metadata +216 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Server
8
+ class ListTasks
9
+ def call(env)
10
+ env["a2a.result"] = Schema["List Tasks Response"].new({})
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Server
8
+ class SendMessage
9
+ def call(env)
10
+ env["a2a.result"] = Schema["Send Message Response"].new({})
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Server
8
+ class SendStreamingMessage
9
+ def call(env)
10
+ env["a2a.result"] = Schema["Stream Response"].new({})
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ module Server
8
+ class SubscribeToTask
9
+ def call(env)
10
+ env["a2a.result"] = Schema["Stream Response"].new({})
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ module A2A
7
+ class Server
8
+ # Rack middleware that resolves which A2A operation a request targets.
9
+ #
10
+ # Reads the raw protocol data left in env by the binding middleware
11
+ # (JSON-RPC method name or HTTP verb + path), looks up the matching
12
+ # Proto::Operation, wraps the request body into a Schema::Definition,
13
+ # and sets env keys for downstream consumption by the Dispatcher.
14
+ #
15
+ # Env keys read:
16
+ # "a2a.json_rpc_method" — set by Bindings::JsonRpc
17
+ # "a2a.verb" — set by Bindings::Rest
18
+ # "a2a.path" — set by Bindings::Rest
19
+ # "a2a.body" — set by either binding
20
+ #
21
+ # Env keys set:
22
+ # "a2a.operation" — operation name string (e.g. "SendMessage")
23
+ # "a2a.proto_operation" — Proto::Operation instance
24
+ # "a2a.request" — Schema::Definition instance (validated request)
25
+ #
26
+ class Triage
27
+ def initialize(app)
28
+ @app = app
29
+ end
30
+
31
+ def call(env)
32
+ op = resolve_operation(env)
33
+
34
+ unless op
35
+ return [404, { "content-type" => "text/plain" }, ["Unknown operation"]]
36
+ end
37
+
38
+ env["a2a.operation"] = op.name
39
+ env["a2a.proto_operation"] = op
40
+
41
+ body = env["a2a.body"] || {}
42
+
43
+ # For REST, merge extracted path params into the body.
44
+ if env["a2a.path"]
45
+ path_params = extract_path_params(op.rest_path, env["a2a.path"])
46
+ body = body.merge(path_params)
47
+ end
48
+
49
+ if op.request_schema
50
+ env["a2a.request"] = op.request_schema.new(body)
51
+ end
52
+
53
+ @app.call(env)
54
+ end
55
+
56
+ private
57
+
58
+ def resolve_operation(env)
59
+ if env["a2a.json_rpc_method"]
60
+ Proto.operation(env["a2a.json_rpc_method"])
61
+ elsif env["a2a.verb"] && env["a2a.path"]
62
+ match_rest_operation(env["a2a.verb"], env["a2a.path"])
63
+ end
64
+ end
65
+
66
+ def match_rest_operation(verb, path)
67
+ Proto.operations.find do |op|
68
+ op.http_bindings.any? do |b|
69
+ b.verb == verb && path_matches?(b.path, path)
70
+ end
71
+ end
72
+ end
73
+
74
+ # Match a proto path pattern like "/tasks/{id=*}" against
75
+ # an actual request path like "/tasks/abc-123".
76
+ def path_matches?(pattern, path)
77
+ regex = pattern_to_regex(pattern)
78
+ path.match?(regex)
79
+ end
80
+
81
+ def pattern_to_regex(pattern)
82
+ re = pattern.gsub(/\{[^}]+\}/, '([^/]+)')
83
+ /\A#{re}\z/
84
+ end
85
+
86
+ def extract_path_params(pattern, path)
87
+ names = pattern.scan(/\{(\w+)(?:=[^}]*)?\}/).flatten
88
+ regex = pattern_to_regex(pattern)
89
+ match = path.match(regex)
90
+ return {} unless match
91
+
92
+ names.zip(match.captures).to_h
93
+ end
94
+ end
95
+ end
96
+ end
data/lib/a2a/server.rb ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "a2a"
5
+
6
+ require "a2a/server/env"
7
+ require "a2a/server/triage"
8
+ require "a2a/server/dispatcher"
9
+
10
+ module A2A
11
+ # Rack application that exposes an A2A-compliant agent server.
12
+ #
13
+ # Composes two separate middleware stacks, one for each protocol binding:
14
+ #
15
+ # /.well-known/agent-card.json
16
+ # → Env → serve agent card
17
+ #
18
+ # /a2a (JSON-RPC 2.0)
19
+ # → Env → Bindings::JsonRpc → Triage → Dispatcher
20
+ #
21
+ # / (HTTP+JSON/REST)
22
+ # → Env → Bindings::Rest → Triage → Dispatcher
23
+ #
24
+ # Usage:
25
+ #
26
+ # agent = A2A::Agent.new do
27
+ # on "SendMessage" do |request|
28
+ # respond A2A::Schema["Send Message Response"].new({})
29
+ # end
30
+ # end
31
+ #
32
+ # app = A2A::Server.new(agent_card: { "name" => "My Agent", ... })
33
+ # app.register(agent)
34
+ #
35
+ # run app
36
+ #
37
+ class Server
38
+ def initialize(agent_card: {}, store: TaskStore.new)
39
+ @agent_card = agent_card
40
+ @store = store
41
+ @dispatcher = Dispatcher.new
42
+ @app = build_app
43
+ end
44
+
45
+ # Register an Agent or a plain handler on the internal dispatcher.
46
+ #
47
+ # Accepts either:
48
+ # - An Agent (responds to #handlers) — registers all its handlers
49
+ # - A plain handler (responds to #operations and #call)
50
+ #
51
+ def register(handler)
52
+ if handler.respond_to?(:handlers)
53
+ handler.handlers.each { |h| @dispatcher.register(h) }
54
+ else
55
+ @dispatcher.register(handler)
56
+ end
57
+ end
58
+
59
+ def call(env)
60
+ @app.call(env)
61
+ end
62
+
63
+ private
64
+
65
+ def build_app
66
+ require "a2a/bindings/json_rpc"
67
+ require "a2a/bindings/rest"
68
+
69
+ agent_card = @agent_card
70
+ store = @store
71
+ dispatcher = @dispatcher
72
+
73
+ Rack::Builder.app do
74
+ use A2A::Server::Env, agent_card: agent_card, store: store
75
+
76
+ map "/.well-known/agent-card.json" do
77
+ run ->(env) {
78
+ card = env["a2a.agent_card"] || {}
79
+ body = card.is_a?(Hash) ? card : card.to_h
80
+ [200, { "content-type" => "application/json" }, [JSON.generate(body)]]
81
+ }
82
+ end
83
+
84
+ map "/a2a" do
85
+ use A2A::Bindings::JsonRpc
86
+ use A2A::Server::Triage
87
+ run dispatcher
88
+ end
89
+
90
+ map "/" do
91
+ use A2A::Bindings::Rest
92
+ use A2A::Server::Triage
93
+ run dispatcher
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,66 @@
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(json_rpc_id: 1)
15
+ #
16
+ # Async do
17
+ # stream.event({ "task" => { ... } })
18
+ # stream.finish
19
+ # end
20
+ #
21
+ # [200, A2A::SSE::Stream.headers, stream]
22
+ #
23
+ class JsonRpcStream < Stream
24
+ def initialize(json_rpc_id:, **options)
25
+ @json_rpc_id = json_rpc_id
26
+ super(**options)
27
+ end
28
+
29
+ # Emit an SSE event wrapped in a JSON-RPC envelope.
30
+ #
31
+ # @param data [Hash] the StreamResponse payload (becomes "result")
32
+ #
33
+ def event(data, **opts)
34
+ envelope = {
35
+ "jsonrpc" => "2.0",
36
+ "id" => @json_rpc_id,
37
+ "result" => data.respond_to?(:to_h) ? data.to_h : data,
38
+ }
39
+ super(envelope, **opts)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ test do
46
+ describe "A2A::SSE::JsonRpcStream" do
47
+ it "wraps events in JSON-RPC 2.0 envelopes" do
48
+ stream = A2A::SSE::JsonRpcStream.new(json_rpc_id: 42)
49
+
50
+ stream.event({ "task" => { "id" => "t1" } })
51
+ stream.finish
52
+
53
+ chunk = stream.read
54
+ chunk.should.include('"jsonrpc"')
55
+ chunk.should.include('"2.0"')
56
+ chunk.should.include('"id":42') # numeric id preserved
57
+ chunk.should.include('"result"')
58
+ end
59
+
60
+ it "is a subclass of SSE::Stream" do
61
+ stream = A2A::SSE::JsonRpcStream.new(json_rpc_id: 1)
62
+ stream.is_a?(A2A::SSE::Stream).should == true
63
+ stream.is_a?(Protocol::HTTP::Body::Readable).should == true
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,50 @@
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
19
+ #
20
+ # Async do
21
+ # stream.event({ "task" => { ... } })
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
36
+
37
+ stream.event({ "statusUpdate" => { "taskId" => "t1" } })
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
47
+ stream.is_a?(A2A::SSE::Stream).should == true
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,129 @@
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::Stream.new
23
+ #
24
+ # Async do
25
+ # stream.event({ "task" => { ... } })
26
+ # stream.event({ "statusUpdate" => { ... } })
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
+ # Emit an SSE event.
42
+ #
43
+ # @param data [Hash, String] the event payload (Hashes are JSON-encoded)
44
+ # @param type [String, nil] optional SSE event type field
45
+ # @param id [String, nil] optional SSE event id field
46
+ #
47
+ def event(data, type: nil, id: nil)
48
+ payload = data.is_a?(String) ? data : JSON.generate(data)
49
+
50
+ buf = String.new
51
+ buf << "event: #{type}\n" if type
52
+ buf << "id: #{id}\n" if id
53
+
54
+ # SSE spec: each line of data gets its own `data:` prefix.
55
+ # For single-line JSON this is one line; multi-line is handled correctly.
56
+ payload.each_line do |line|
57
+ buf << "data: #{line.chomp}\n"
58
+ end
59
+ buf << "\n" # blank line terminates the event
60
+
61
+ write(buf)
62
+ end
63
+
64
+ # Signal end-of-stream.
65
+ # The reader will receive nil on next read(), closing the SSE connection.
66
+ def finish
67
+ close_write
68
+ end
69
+
70
+ # Convenience: the SSE headers to return in the Rack response.
71
+ def self.headers
72
+ SSE_HEADERS
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ test do
79
+ require "async"
80
+
81
+ describe "A2A::SSE::Stream" do
82
+ it "formats SSE events correctly" do
83
+ stream = A2A::SSE::Stream.new
84
+
85
+ stream.event({ "task" => { "id" => "t1" } })
86
+ stream.finish
87
+
88
+ chunk = stream.read
89
+ chunk.should.include("data: ")
90
+ chunk.should.include('"task"')
91
+ chunk.should.end_with("\n\n")
92
+
93
+ # After finish, read returns nil
94
+ stream.read.should.be.nil
95
+ end
96
+
97
+ it "includes event type when provided" do
98
+ stream = A2A::SSE::Stream.new
99
+
100
+ stream.event("hello", type: "ping")
101
+ stream.finish
102
+
103
+ chunk = stream.read
104
+ chunk.should.include("event: ping\n")
105
+ chunk.should.include("data: hello\n")
106
+ end
107
+
108
+ it "includes event id when provided" do
109
+ stream = A2A::SSE::Stream.new
110
+
111
+ stream.event("test", id: "42")
112
+ stream.finish
113
+
114
+ chunk = stream.read
115
+ chunk.should.include("id: 42\n")
116
+ end
117
+
118
+ it "is a Protocol::HTTP::Body::Readable" do
119
+ stream = A2A::SSE::Stream.new
120
+ stream.is_a?(Protocol::HTTP::Body::Readable).should == true
121
+ end
122
+
123
+ it "provides standard SSE headers" do
124
+ headers = A2A::SSE::Stream.headers
125
+ headers["content-type"].should == "text/event-stream"
126
+ headers["cache-control"].should.include("no-cache")
127
+ end
128
+ end
129
+ end
data/lib/a2a/sse.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sse/stream"
4
+ require_relative "sse/json_rpc_stream"
5
+ require_relative "sse/rest_stream"
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "console"
5
+
6
+ module A2A
7
+ module Store
8
+ # Async background task processor, modeled after async-job's Inline processor.
9
+ #
10
+ # The gospel (async-job) teaches:
11
+ # - Async::Idler schedules tasks when the event loop is idle (backpressure)
12
+ # - Each job runs in its own fiber (not thread)
13
+ # - The returned Async::Task can be .wait'd for completion notification
14
+ # - Errors are caught and logged, not re-raised
15
+ # - task.defer_stop protects critical sections from interruption
16
+ #
17
+ # This processor enables A2A's non-blocking mode (return_immediately: true).
18
+ # The handler can enqueue work that executes after the HTTP response is sent.
19
+ #
20
+ # Usage:
21
+ #
22
+ # processor = A2A::Store::Processor.new
23
+ #
24
+ # # Fire and forget:
25
+ # processor.call { store.update_state(task_id, "WORKING"); do_work; store.complete(task_id, result) }
26
+ #
27
+ # # Wait for completion:
28
+ # task = processor.call { do_work }
29
+ # task.wait
30
+ #
31
+ class Processor
32
+ attr_reader :call_count, :complete_count, :failed_count
33
+
34
+ def initialize(parent: nil)
35
+ @parent = parent
36
+ @call_count = 0
37
+ @complete_count = 0
38
+ @failed_count = 0
39
+ end
40
+
41
+ # Execute a block asynchronously in a background fiber.
42
+ #
43
+ # Returns the Async::Task so callers can optionally .wait on it.
44
+ #
45
+ # @yield the work to perform
46
+ # @return [Async::Task]
47
+ #
48
+ def call(&block)
49
+ @call_count += 1
50
+
51
+ parent.async do |task|
52
+ task.defer_stop do
53
+ yield
54
+ end
55
+ @complete_count += 1
56
+ rescue => error
57
+ @failed_count += 1
58
+ Console.error(self) { "Background task failed: #{error.message}" }
59
+ ensure
60
+ @call_count -= 1
61
+ end
62
+ end
63
+
64
+ def start
65
+ # Ensure we have an async context available
66
+ end
67
+
68
+ def stop
69
+ # Allow in-flight tasks to drain naturally
70
+ end
71
+
72
+ def status
73
+ {
74
+ in_flight: @call_count,
75
+ completed: @complete_count,
76
+ failed: @failed_count,
77
+ }
78
+ end
79
+
80
+ private
81
+
82
+ def parent
83
+ @parent || Async::Task.current
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ test do
90
+ require "async"
91
+
92
+ describe "A2A::Store::Processor" do
93
+ it "executes blocks asynchronously" do
94
+ executed = false
95
+
96
+ Sync do
97
+ processor = A2A::Store::Processor.new
98
+ task = processor.call { executed = true }
99
+ task.wait
100
+ end
101
+
102
+ executed.should == true
103
+ end
104
+
105
+ it "tracks call counts" do
106
+ Sync do
107
+ processor = A2A::Store::Processor.new
108
+ task = processor.call { "work" }
109
+ task.wait
110
+
111
+ processor.complete_count.should == 1
112
+ processor.call_count.should == 0 # decremented after completion
113
+ end
114
+ end
115
+
116
+ it "handles errors gracefully" do
117
+ Sync do
118
+ processor = A2A::Store::Processor.new
119
+ task = processor.call { raise "boom" }
120
+ task.wait rescue nil # task may re-raise
121
+
122
+ processor.failed_count.should == 1
123
+ end
124
+ end
125
+
126
+ it "returns status" do
127
+ Sync do
128
+ processor = A2A::Store::Processor.new
129
+ status = processor.status
130
+ status[:in_flight].should == 0
131
+ status[:completed].should == 0
132
+ status[:failed].should == 0
133
+ end
134
+ end
135
+ end
136
+ end