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.
- checksums.yaml +7 -0
- data/data/a2a.json +1961 -0
- data/data/a2a.proto +796 -0
- data/data/download-a2a-resources +4 -0
- data/data/spec.txt +3611 -0
- data/lib/a2a/agent.rb +243 -0
- data/lib/a2a/bindings/json_rpc.rb +110 -0
- data/lib/a2a/bindings/rest.rb +106 -0
- data/lib/a2a/client.rb +85 -0
- data/lib/a2a/proto.rb +444 -0
- data/lib/a2a/schema/definition.rb +114 -0
- data/lib/a2a/schema/validation_error.rb +129 -0
- data/lib/a2a/schema.rb +429 -0
- data/lib/a2a/server/cancel_task.rb +14 -0
- data/lib/a2a/server/create_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/delete_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/dispatcher.rb +127 -0
- data/lib/a2a/server/env.rb +28 -0
- data/lib/a2a/server/get_extended_agent_card.rb +15 -0
- data/lib/a2a/server/get_task.rb +14 -0
- data/lib/a2a/server/get_task_push_notification_config.rb +14 -0
- data/lib/a2a/server/list_task_push_notification_configs.rb +14 -0
- data/lib/a2a/server/list_tasks.rb +14 -0
- data/lib/a2a/server/send_message.rb +14 -0
- data/lib/a2a/server/send_streaming_message.rb +14 -0
- data/lib/a2a/server/subscribe_to_task.rb +14 -0
- data/lib/a2a/server/triage.rb +96 -0
- data/lib/a2a/server.rb +98 -0
- data/lib/a2a/sse/json_rpc_stream.rb +66 -0
- data/lib/a2a/sse/rest_stream.rb +50 -0
- data/lib/a2a/sse/stream.rb +129 -0
- data/lib/a2a/sse.rb +5 -0
- data/lib/a2a/store/processor.rb +136 -0
- data/lib/a2a/store/pub_sub.rb +149 -0
- data/lib/a2a/store/sqlite.rb +533 -0
- data/lib/a2a/store/webhooks.rb +94 -0
- data/lib/a2a/store.rb +6 -0
- data/lib/a2a/task_store.rb +315 -0
- data/lib/a2a/version.rb +5 -0
- data/lib/a2a.rb +26 -0
- metadata +216 -0
|
@@ -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,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
|