simple_a2a 0.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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/deploy-github-pages.yml +52 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +192 -0
  6. data/Rakefile +13 -0
  7. data/docs/api/client/index.md +124 -0
  8. data/docs/api/index.md +27 -0
  9. data/docs/api/models/index.md +233 -0
  10. data/docs/api/server/index.md +162 -0
  11. data/docs/api/storage/index.md +84 -0
  12. data/docs/architecture/index.md +63 -0
  13. data/docs/architecture/protocol.md +112 -0
  14. data/docs/assets/css/custom.css +6 -0
  15. data/docs/examples/basic-usage.md +77 -0
  16. data/docs/examples/index.md +92 -0
  17. data/docs/examples/llm-research.md +92 -0
  18. data/docs/examples/streaming.md +81 -0
  19. data/docs/getting-started/installation.md +48 -0
  20. data/docs/getting-started/quick-start.md +100 -0
  21. data/docs/guides/custom-storage.md +69 -0
  22. data/docs/guides/push-notifications.md +104 -0
  23. data/docs/guides/streaming.md +75 -0
  24. data/docs/index.md +98 -0
  25. data/examples/01_basic_usage/client.rb +75 -0
  26. data/examples/01_basic_usage/server.rb +57 -0
  27. data/examples/02_streaming/client.rb +70 -0
  28. data/examples/02_streaming/server.rb +177 -0
  29. data/examples/03_llm_research/client.rb +138 -0
  30. data/examples/03_llm_research/run +82 -0
  31. data/examples/03_llm_research/server.rb +203 -0
  32. data/examples/03_llm_research/web_client.rb +501 -0
  33. data/examples/common_config.rb +4 -0
  34. data/examples/run +108 -0
  35. data/lib/simple_a2a/client/base.rb +101 -0
  36. data/lib/simple_a2a/client/sse.rb +58 -0
  37. data/lib/simple_a2a/errors.rb +15 -0
  38. data/lib/simple_a2a/json_rpc.rb +89 -0
  39. data/lib/simple_a2a/models/agent_capabilities.rb +11 -0
  40. data/lib/simple_a2a/models/agent_card.rb +23 -0
  41. data/lib/simple_a2a/models/agent_interface.rb +11 -0
  42. data/lib/simple_a2a/models/agent_provider.rb +11 -0
  43. data/lib/simple_a2a/models/agent_skill.rb +12 -0
  44. data/lib/simple_a2a/models/artifact.rb +23 -0
  45. data/lib/simple_a2a/models/authentication_info.rb +11 -0
  46. data/lib/simple_a2a/models/base.rb +111 -0
  47. data/lib/simple_a2a/models/message.rb +45 -0
  48. data/lib/simple_a2a/models/part.rb +45 -0
  49. data/lib/simple_a2a/models/push_notification_config.rb +17 -0
  50. data/lib/simple_a2a/models/security_scheme.rb +16 -0
  51. data/lib/simple_a2a/models/send_message_configuration.rb +12 -0
  52. data/lib/simple_a2a/models/stream_response.rb +32 -0
  53. data/lib/simple_a2a/models/task.rb +57 -0
  54. data/lib/simple_a2a/models/task_artifact_update_event.rb +21 -0
  55. data/lib/simple_a2a/models/task_status.rb +20 -0
  56. data/lib/simple_a2a/models/task_status_update_event.rb +19 -0
  57. data/lib/simple_a2a/models/types.rb +39 -0
  58. data/lib/simple_a2a/server/agent_executor.rb +16 -0
  59. data/lib/simple_a2a/server/app.rb +227 -0
  60. data/lib/simple_a2a/server/base.rb +43 -0
  61. data/lib/simple_a2a/server/context.rb +44 -0
  62. data/lib/simple_a2a/server/event_router.rb +50 -0
  63. data/lib/simple_a2a/server/falcon_runner.rb +31 -0
  64. data/lib/simple_a2a/server/multi_agent.rb +50 -0
  65. data/lib/simple_a2a/server/push_sender.rb +80 -0
  66. data/lib/simple_a2a/server/resume_context.rb +14 -0
  67. data/lib/simple_a2a/storage/base.rb +12 -0
  68. data/lib/simple_a2a/storage/memory.rb +41 -0
  69. data/lib/simple_a2a/version.rb +5 -0
  70. data/lib/simple_a2a.rb +49 -0
  71. data/mkdocs.yml +143 -0
  72. data/sig/simple_a2a.rbs +4 -0
  73. metadata +353 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class PushNotificationConfig < Base
6
+ attribute :id
7
+ attribute :task_id
8
+ attribute :webhook_url, required: true
9
+ attribute :authentication_info, type: AuthenticationInfo
10
+ attribute :event_types, default: -> { [] }
11
+
12
+ def valid?
13
+ !webhook_url.nil? && !webhook_url.empty?
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class SecurityScheme < Base
6
+ attribute :type, required: true
7
+ attribute :description
8
+ attribute :scheme
9
+ attribute :bearer_format
10
+ attribute :flows
11
+ attribute :open_id_connect_url
12
+ attribute :in
13
+ attribute :name
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class SendMessageConfiguration < Base
6
+ attribute :accepted_output_modes, default: -> { [] }
7
+ attribute :task_push_notification_config
8
+ attribute :history_length
9
+ attribute :return_immediately, default: false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class StreamResponse < Base
6
+ attribute :task
7
+ attribute :message
8
+ attribute :status_update
9
+ attribute :artifact_update
10
+
11
+ def task? = !task.nil?
12
+ def message? = !message.nil?
13
+ def status_update? = !status_update.nil?
14
+ def artifact_update? = !artifact_update.nil?
15
+
16
+ def self.from_hash(hash)
17
+ return nil if hash.nil?
18
+ if hash["task"]
19
+ new(task: Task.from_hash(hash["task"]))
20
+ elsif hash["message"]
21
+ new(message: Message.from_hash(hash["message"]))
22
+ elsif hash["statusUpdate"]
23
+ new(status_update: hash["statusUpdate"])
24
+ elsif hash["artifactUpdate"]
25
+ new(artifact_update: hash["artifactUpdate"])
26
+ else
27
+ new
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class Task < Base
6
+ attribute :id
7
+ attribute :context_id
8
+ attribute :status, type: TaskStatus, required: true
9
+ attribute :artifacts, type: [Artifact], default: -> { [] }
10
+ attribute :history, type: [Message], default: -> { [] }
11
+ attribute :metadata
12
+
13
+ def initialize(**kwargs)
14
+ kwargs[:id] ||= SecureRandom.uuid
15
+ kwargs[:context_id] ||= SecureRandom.uuid
16
+ super
17
+ end
18
+
19
+ def state = status&.state
20
+ def terminal? = status&.terminal? || false
21
+ def interrupted? = status&.interrupted? || false
22
+
23
+ def start!
24
+ transition!(Types::TaskState::WORKING)
25
+ end
26
+
27
+ def complete!(artifacts: [])
28
+ self.artifacts = artifacts unless artifacts.empty?
29
+ transition!(Types::TaskState::COMPLETED)
30
+ end
31
+
32
+ def fail!(message: nil)
33
+ transition!(Types::TaskState::FAILED, message: message)
34
+ end
35
+
36
+ def cancel!
37
+ transition!(Types::TaskState::CANCELED)
38
+ end
39
+
40
+ def reject!(message: nil)
41
+ transition!(Types::TaskState::REJECTED, message: message)
42
+ end
43
+
44
+ def require_input!(message: nil)
45
+ transition!(Types::TaskState::INPUT_REQUIRED, message: message)
46
+ end
47
+
48
+ def require_auth!(message: nil)
49
+ transition!(Types::TaskState::AUTH_REQUIRED, message: message)
50
+ end
51
+
52
+ def transition!(new_state, message: nil)
53
+ self.status = TaskStatus.new(state: new_state, message: message)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class TaskArtifactUpdateEvent < Base
6
+ attribute :task_id, required: true
7
+ attribute :context_id, required: true
8
+ attribute :artifact, type: Artifact, required: true
9
+ attribute :append, default: false
10
+ attribute :last_chunk, default: false
11
+ attribute :metadata
12
+
13
+ def append? = !!append
14
+ def last_chunk? = !!last_chunk
15
+
16
+ def to_h
17
+ super.merge("type" => "TaskArtifactUpdateEvent")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class TaskStatus < Base
6
+ attribute :state, required: true
7
+ attribute :message, type: Message
8
+ attribute :timestamp
9
+
10
+ def initialize(**kwargs)
11
+ kwargs[:timestamp] ||= Time.now.iso8601
12
+ super
13
+ end
14
+
15
+ def terminal? = Types::TaskState.terminal?(state)
16
+ def interrupted? = Types::TaskState.interrupted?(state)
17
+ def active? = Types::TaskState.active?(state)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ class TaskStatusUpdateEvent < Base
6
+ attribute :task_id, required: true
7
+ attribute :context_id, required: true
8
+ attribute :status, type: TaskStatus, required: true
9
+ attribute :final, default: false
10
+ attribute :metadata
11
+
12
+ def final? = !!final
13
+
14
+ def to_h
15
+ super.merge("type" => "TaskStatusUpdateEvent")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Models
5
+ module Types
6
+ module TaskState
7
+ SUBMITTED = "submitted"
8
+ WORKING = "working"
9
+ COMPLETED = "completed"
10
+ FAILED = "failed"
11
+ CANCELED = "canceled"
12
+ REJECTED = "rejected"
13
+ INPUT_REQUIRED = "input_required"
14
+ AUTH_REQUIRED = "auth_required"
15
+
16
+ TERMINAL = [COMPLETED, FAILED, CANCELED, REJECTED].freeze
17
+ INTERRUPTED = [INPUT_REQUIRED, AUTH_REQUIRED].freeze
18
+ ACTIVE = [SUBMITTED, WORKING].freeze
19
+ ALL = (TERMINAL + INTERRUPTED + ACTIVE).freeze
20
+
21
+ def self.terminal?(state) = TERMINAL.include?(state)
22
+ def self.interrupted?(state) = INTERRUPTED.include?(state)
23
+ def self.active?(state) = ACTIVE.include?(state)
24
+ end
25
+
26
+ module Role
27
+ USER = "user"
28
+ AGENT = "agent"
29
+ ALL = [USER, AGENT].freeze
30
+ end
31
+
32
+ module BindingType
33
+ JSON_RPC = "json-rpc"
34
+ HTTP = "http"
35
+ GRPC = "grpc"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Server
5
+ class AgentExecutor
6
+ def call(context)
7
+ raise NotImplementedError, "#{self.class}#call must be implemented"
8
+ end
9
+
10
+ def cancel(context)
11
+ context.task.cancel!
12
+ context.emit_status(final: true)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+ require "protocol/http/body/writable"
5
+
6
+ module A2A
7
+ module Server
8
+ class App < Roda
9
+ SUPPORTED_VERSIONS = %w[1.0 0.3].freeze
10
+
11
+ plugin :json
12
+ plugin :json_parser
13
+ plugin :halt
14
+ plugin :all_verbs
15
+
16
+ def self.configure(agent_card:, storage:, executor:, event_router:, push_sender: nil)
17
+ @agent_card = agent_card
18
+ @storage = storage
19
+ @executor = executor
20
+ @event_router = event_router
21
+ @push_sender = push_sender
22
+ end
23
+
24
+ class << self
25
+ attr_reader :agent_card, :storage, :executor, :event_router, :push_sender
26
+ end
27
+
28
+ route do |r|
29
+ # A2A version negotiation
30
+ a2a_version = request.env["HTTP_A2A_VERSION"]
31
+ if a2a_version && !SUPPORTED_VERSIONS.include?(a2a_version)
32
+ err = JsonRpc::Response.error(
33
+ id: nil,
34
+ code: JsonRpc::ErrorCode::VERSION_NOT_SUPPORTED,
35
+ message: "Unsupported A2A version: #{a2a_version}"
36
+ )
37
+ r.halt([200, { "Content-Type" => "application/json" }, [err]])
38
+ end
39
+
40
+ r.get "agentCard" do
41
+ self.class.agent_card.to_h
42
+ end
43
+
44
+ r.post do
45
+ body = request.body.read
46
+ rpc_req = begin
47
+ JsonRpc::Request.parse(body)
48
+ rescue JsonRpc::ParseError => e
49
+ err = JsonRpc::Response.error(id: nil, code: JsonRpc::ErrorCode::PARSE_ERROR, message: e.message)
50
+ r.halt([200, { "Content-Type" => "application/json" }, [err]])
51
+ rescue JsonRpc::InvalidRequestError => e
52
+ err = JsonRpc::Response.error(id: nil, code: JsonRpc::ErrorCode::INVALID_REQUEST, message: e.message)
53
+ r.halt([200, { "Content-Type" => "application/json" }, [err]])
54
+ end
55
+
56
+ # SSE has different headers and body — intercept before normal dispatch.
57
+ # The body is an Enumerator so Falcon streams each event to the client
58
+ # as the executor yields it, rather than buffering the whole response.
59
+ if rpc_req.method == "tasks/sendSubscribe"
60
+ r.halt([200, {
61
+ "Content-Type" => "text/event-stream",
62
+ "Cache-Control" => "no-cache",
63
+ "X-Accel-Buffering" => "no"
64
+ }, handle_send_subscribe(rpc_req)])
65
+ end
66
+
67
+ result = dispatch(rpc_req)
68
+ response["Content-Type"] = "application/json"
69
+ result
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def dispatch(rpc_req)
76
+ case rpc_req.method
77
+ when "tasks/send" then handle_send(rpc_req)
78
+ when "tasks/get" then handle_get(rpc_req)
79
+ when "tasks/list" then handle_list(rpc_req)
80
+ when "tasks/cancel" then handle_cancel(rpc_req)
81
+ when "tasks/pushNotification/set" then handle_push_set(rpc_req)
82
+ when "tasks/pushNotification/get" then handle_push_get(rpc_req)
83
+ when "tasks/pushNotification/delete" then handle_push_delete(rpc_req)
84
+ when "tasks/pushNotification/list" then handle_push_list(rpc_req)
85
+ else
86
+ JsonRpc::Response.error(
87
+ id: rpc_req.id,
88
+ code: JsonRpc::ErrorCode::METHOD_NOT_FOUND,
89
+ message: "Method not found: #{rpc_req.method}"
90
+ )
91
+ end
92
+ rescue A2A::Error => e
93
+ JsonRpc::Response.from_error(id: rpc_req.id, error: e)
94
+ rescue StandardError => e
95
+ JsonRpc::Response.from_error(id: rpc_req.id, error: e)
96
+ end
97
+
98
+ def handle_send(rpc_req)
99
+ params = rpc_req.params || {}
100
+ msg_hash = params["message"]
101
+ raise JsonRpc::InvalidParamsError, "message is required" unless msg_hash.is_a?(Hash)
102
+ message = Models::Message.from_hash(msg_hash)
103
+
104
+ task = Models::Task.new(
105
+ status: Models::TaskStatus.new(state: Models::Types::TaskState::SUBMITTED)
106
+ )
107
+ self.class.storage.save(task)
108
+
109
+ ctx = Server::Context.new(
110
+ task: task,
111
+ message: message,
112
+ storage: self.class.storage,
113
+ event_router: self.class.event_router
114
+ )
115
+ self.class.executor.call(ctx)
116
+ self.class.storage.save(task)
117
+
118
+ JsonRpc::Response.success(id: rpc_req.id, result: task.to_h)
119
+ end
120
+
121
+ def handle_get(rpc_req)
122
+ params = rpc_req.params || {}
123
+ task_id = params["id"] || params["taskId"]
124
+ raise JsonRpc::InvalidParamsError, "id is required" unless task_id
125
+
126
+ task = self.class.storage.find!(task_id)
127
+ JsonRpc::Response.success(id: rpc_req.id, result: task.to_h)
128
+ end
129
+
130
+ def handle_list(rpc_req)
131
+ tasks = self.class.storage.list
132
+ JsonRpc::Response.success(id: rpc_req.id, result: tasks.map(&:to_h))
133
+ end
134
+
135
+ def handle_cancel(rpc_req)
136
+ params = rpc_req.params || {}
137
+ task_id = params["id"] || params["taskId"]
138
+ raise JsonRpc::InvalidParamsError, "id is required" unless task_id
139
+
140
+ task = self.class.storage.find!(task_id)
141
+ raise TaskNotCancelableError, "Task #{task_id} is already terminal" if task.terminal?
142
+
143
+ ctx = Server::Context.new(
144
+ task: task,
145
+ message: nil,
146
+ storage: self.class.storage,
147
+ event_router: self.class.event_router
148
+ )
149
+ self.class.executor.cancel(ctx)
150
+ self.class.storage.save(task)
151
+
152
+ JsonRpc::Response.success(id: rpc_req.id, result: task.to_h)
153
+ end
154
+
155
+ def handle_send_subscribe(rpc_req)
156
+ params = rpc_req.params || {}
157
+ msg_hash = params["message"]
158
+ raise JsonRpc::InvalidParamsError, "message is required" unless msg_hash.is_a?(Hash)
159
+ message = Models::Message.from_hash(msg_hash)
160
+
161
+ task = Models::Task.new(
162
+ status: Models::TaskStatus.new(state: Models::Types::TaskState::SUBMITTED)
163
+ )
164
+ self.class.storage.save(task)
165
+
166
+ storage = self.class.storage
167
+ executor = self.class.executor
168
+
169
+ # Protocol::HTTP::Body::Writable is a Readable subclass.
170
+ # Protocol::Rack detects this and does NOT wrap it in an Enumerable
171
+ # fiber — Falcon reads it directly via queue.dequeue in its async task.
172
+ # The executor runs in a sibling async task and pushes events via
173
+ # output.write (queue.enqueue), which is fully async-safe.
174
+ # This avoids the FiberError that Enumerator::Yielder#<< raises when
175
+ # called from inside an LLM streaming callback (an async task context
176
+ # where the Enumerator consumer fiber is not currently waiting).
177
+ output = Protocol::HTTP::Body::Writable.new
178
+
179
+ Async::Task.current.async do
180
+ streaming_router = Object.new.tap do |ro|
181
+ ro.define_singleton_method(:publish) { |_, ev| output.write("data: #{JSON.generate({ 'jsonrpc' => '2.0', 'result' => ev.to_h })}\n\n") }
182
+ ro.define_singleton_method(:open) { |*| }
183
+ ro.define_singleton_method(:close) { |*| }
184
+ ro.define_singleton_method(:channel?) { |*| false }
185
+ ro.define_singleton_method(:subscribe) { |*| }
186
+ end
187
+
188
+ ctx = Server::Context.new(
189
+ task: task,
190
+ message: message,
191
+ storage: storage,
192
+ event_router: streaming_router
193
+ )
194
+
195
+ executor.call(ctx)
196
+ storage.save(task)
197
+ rescue => e
198
+ output.write("data: #{JSON.generate({ 'jsonrpc' => '2.0', 'error' => { 'code' => -32000, 'message' => e.message } })}\n\n") rescue nil
199
+ ensure
200
+ output.close_write rescue nil
201
+ end
202
+
203
+ output
204
+ end
205
+
206
+ def handle_push_set(rpc_req)
207
+ raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
208
+ JsonRpc::Response.success(id: rpc_req.id, result: true)
209
+ end
210
+
211
+ def handle_push_get(rpc_req)
212
+ raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
213
+ JsonRpc::Response.success(id: rpc_req.id, result: nil)
214
+ end
215
+
216
+ def handle_push_delete(rpc_req)
217
+ raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
218
+ JsonRpc::Response.success(id: rpc_req.id, result: true)
219
+ end
220
+
221
+ def handle_push_list(rpc_req)
222
+ raise PushNotificationNotSupportedError unless self.class.agent_card&.capabilities&.push_notifications
223
+ JsonRpc::Response.success(id: rpc_req.id, result: [])
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Server
5
+ class Base
6
+ attr_reader :agent_card, :executor, :storage, :event_router, :push_sender
7
+
8
+ def initialize(
9
+ agent_card:,
10
+ executor:,
11
+ storage: Storage::Memory.new,
12
+ push_sender: nil,
13
+ host: "localhost",
14
+ port: 9292
15
+ )
16
+ @agent_card = agent_card
17
+ @executor = executor
18
+ @storage = storage
19
+ @event_router = EventRouter.new
20
+ @push_sender = push_sender
21
+ @host = host
22
+ @port = port
23
+ end
24
+
25
+ def rack_app
26
+ klass = Class.new(App)
27
+ klass.configure(
28
+ agent_card: @agent_card,
29
+ storage: @storage,
30
+ executor: @executor,
31
+ event_router: @event_router,
32
+ push_sender: @push_sender
33
+ )
34
+ klass.freeze.app
35
+ end
36
+
37
+ def run
38
+ app = rack_app
39
+ FalconRunner.new(app, host: @host, port: @port).run
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Server
5
+ class Context
6
+ attr_reader :task, :message, :storage, :event_router, :config
7
+
8
+ def initialize(task:, message:, storage:, event_router:, config: nil)
9
+ @task = task
10
+ @message = message
11
+ @storage = storage
12
+ @event_router = event_router
13
+ @config = config || {}
14
+ end
15
+
16
+ def save_task
17
+ storage.save(task)
18
+ end
19
+
20
+ def emit_status(final: false)
21
+ event = Models::TaskStatusUpdateEvent.new(
22
+ task_id: task.id,
23
+ context_id: task.context_id,
24
+ status: task.status,
25
+ final: final
26
+ )
27
+ storage.save(task)
28
+ event_router.publish(task.id, event)
29
+ end
30
+
31
+ def emit_artifact(artifact, append: false, last_chunk: false)
32
+ event = Models::TaskArtifactUpdateEvent.new(
33
+ task_id: task.id,
34
+ context_id: task.context_id,
35
+ artifact: artifact,
36
+ append: append,
37
+ last_chunk: last_chunk
38
+ )
39
+ event_router.publish(task.id, event)
40
+ end
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typed_bus"
4
+
5
+ module A2A
6
+ module Server
7
+ class EventRouter
8
+ def initialize
9
+ @bus = TypedBus::MessageBus.new
10
+ end
11
+
12
+ def open(task_id)
13
+ return if @bus.channel?(task_id.to_sym)
14
+ @bus.add_channel(task_id.to_sym, type: nil, timeout: nil)
15
+ end
16
+
17
+ def close(task_id)
18
+ @bus.remove_channel(task_id.to_sym)
19
+ end
20
+
21
+ def publish(task_id, event)
22
+ sym = task_id.to_sym
23
+ open(task_id) unless @bus.channel?(sym)
24
+ @bus.publish(sym, event)
25
+ rescue ArgumentError
26
+ nil
27
+ end
28
+
29
+ def subscribe(task_id, &block)
30
+ sym = task_id.to_sym
31
+ open(task_id) unless @bus.channel?(sym)
32
+ @bus.subscribe(sym) do |delivery|
33
+ block.call(delivery.message)
34
+ delivery.ack!
35
+ end
36
+ end
37
+
38
+ def unsubscribe(task_id, id_or_block)
39
+ return unless @bus.channel?(task_id.to_sym)
40
+ @bus.unsubscribe(task_id.to_sym, id_or_block)
41
+ rescue ArgumentError
42
+ nil
43
+ end
44
+
45
+ def channel?(task_id)
46
+ @bus.channel?(task_id.to_sym)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "falcon"
4
+ require "async"
5
+ require "async/http/endpoint"
6
+
7
+ module A2A
8
+ module Server
9
+ class FalconRunner
10
+ DEFAULT_HOST = "localhost"
11
+ DEFAULT_PORT = 9292
12
+
13
+ def initialize(app, host: DEFAULT_HOST, port: DEFAULT_PORT)
14
+ @app = app
15
+ @host = host
16
+ @port = port
17
+ end
18
+
19
+ def run
20
+ endpoint = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
21
+ server = Falcon::Server.new(Falcon::Server.middleware(@app), endpoint)
22
+
23
+ Async do
24
+ server.run
25
+ end
26
+ rescue Interrupt
27
+ # Ctrl-C — clean shutdown, no backtrace needed
28
+ end
29
+ end
30
+ end
31
+ end