simple_a2a 0.1.0 → 0.3.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +78 -38
  4. data/compare_agent2agent.md +460 -0
  5. data/docs/api/client/index.md +19 -0
  6. data/docs/api/index.md +4 -3
  7. data/docs/api/models/index.md +13 -11
  8. data/docs/api/server/index.md +42 -10
  9. data/docs/api/storage/index.md +0 -1
  10. data/docs/architecture/index.md +17 -15
  11. data/docs/architecture/protocol.md +16 -1
  12. data/docs/assets/images/simple_a2a.jpg +0 -0
  13. data/docs/examples/agent-chaining.md +107 -0
  14. data/docs/examples/auth-headers.md +105 -0
  15. data/docs/examples/cancellation.md +105 -0
  16. data/docs/examples/index.md +123 -52
  17. data/docs/examples/interrupted-states.md +114 -0
  18. data/docs/examples/multipart.md +103 -0
  19. data/docs/examples/push-notifications.md +117 -0
  20. data/docs/examples/resubscribe.md +129 -0
  21. data/docs/examples/sqlite-storage.md +131 -0
  22. data/docs/examples/streaming.md +1 -4
  23. data/docs/guides/push-notifications.md +4 -1
  24. data/docs/guides/streaming.md +34 -5
  25. data/docs/index.md +55 -27
  26. data/examples/04_resubscribe/client.rb +140 -0
  27. data/examples/04_resubscribe/server.rb +75 -0
  28. data/examples/05_cancellation/client.rb +150 -0
  29. data/examples/05_cancellation/server.rb +77 -0
  30. data/examples/06_push_notifications/client.rb +192 -0
  31. data/examples/06_push_notifications/server.rb +123 -0
  32. data/examples/07_agent_chaining/client.rb +120 -0
  33. data/examples/07_agent_chaining/server.rb +150 -0
  34. data/examples/08_interrupted_states/client.rb +148 -0
  35. data/examples/08_interrupted_states/server.rb +142 -0
  36. data/examples/09_multipart/client.rb +117 -0
  37. data/examples/09_multipart/server.rb +97 -0
  38. data/examples/10_auth_headers/client.rb +92 -0
  39. data/examples/10_auth_headers/server.rb +98 -0
  40. data/examples/11_sqlite_storage/Brewfile +1 -0
  41. data/examples/11_sqlite_storage/Gemfile +9 -0
  42. data/examples/11_sqlite_storage/client.rb +114 -0
  43. data/examples/11_sqlite_storage/run +154 -0
  44. data/examples/11_sqlite_storage/server.rb +131 -0
  45. data/examples/README.md +384 -0
  46. data/lib/simple_a2a/client/sse.rb +15 -0
  47. data/lib/simple_a2a/server/app.rb +131 -45
  48. data/lib/simple_a2a/server/base.rb +19 -17
  49. data/lib/simple_a2a/server/broadcast_registry.rb +24 -0
  50. data/lib/simple_a2a/server/multi_agent.rb +1 -1
  51. data/lib/simple_a2a/server/push_config_store.rb +29 -0
  52. data/lib/simple_a2a/server/push_sender.rb +1 -0
  53. data/lib/simple_a2a/server/task_broadcast.rb +46 -0
  54. data/lib/simple_a2a/version.rb +1 -1
  55. metadata +38 -20
  56. data/lib/simple_a2a/server/event_router.rb +0 -50
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/06_push_notifications/client.rb
5
+ #
6
+ # Start the server first:
7
+ # bundle exec ruby examples/06_push_notifications/server.rb
8
+ #
9
+ # What this demo shows:
10
+ # 1. A local webhook receiver starts on port 9293 to capture push deliveries.
11
+ # 2. A task is submitted via tasks/sendSubscribe to get its ID immediately.
12
+ # 3. The client registers a webhook URL via tasks/pushNotification/set.
13
+ # 4. The server delivers an HTTP POST to the webhook after each step — no
14
+ # open SSE connection required for the client to receive updates.
15
+ # 5. tasks/pushNotification/get and list confirm the config is stored.
16
+ # 6. tasks/pushNotification/delete removes the config; list confirms it gone.
17
+
18
+ require_relative "../common_config"
19
+ require "webrick"
20
+ require "json"
21
+
22
+ A2A_URL = "http://localhost:9292"
23
+ WEBHOOK_PORT = 9293
24
+ WEBHOOK_URL = "http://localhost:#{WEBHOOK_PORT}/webhook"
25
+
26
+ def divider = puts("─" * 60)
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Webhook receiver — a minimal WEBrick server that captures every incoming
30
+ # POST and forwards the parsed payload to a Queue for the main thread.
31
+ # ---------------------------------------------------------------------------
32
+ webhook_queue = Queue.new
33
+ webhook_server = WEBrick::HTTPServer.new(
34
+ Port: WEBHOOK_PORT,
35
+ Logger: WEBrick::Log.new(File::NULL),
36
+ AccessLog: []
37
+ )
38
+ webhook_server.mount_proc "/webhook" do |req, res|
39
+ payload = JSON.parse(req.body) rescue { "raw" => req.body }
40
+ webhook_queue << payload
41
+ res.status = 200
42
+ res.body = "ok"
43
+ end
44
+ webhook_thread = Thread.new { webhook_server.start }
45
+ at_exit { webhook_server.shutdown }
46
+
47
+ puts
48
+ puts "=== Webhook receiver listening on #{WEBHOOK_URL} ==="
49
+ puts
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Confirm the server is up and supports push notifications
53
+ # ---------------------------------------------------------------------------
54
+ base_client = A2A.client(url: A2A_URL)
55
+ card = base_client.agent_card
56
+
57
+ puts "=== Agent Card ==="
58
+ puts " Name: #{card.name}"
59
+ puts " Description: #{card.description}"
60
+ puts " push_notifications: #{card.capabilities&.push_notifications}"
61
+ puts " streaming: #{card.capabilities&.streaming}"
62
+ puts
63
+ abort "Agent does not advertise push notification support." unless card.capabilities&.push_notifications
64
+ divider
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Submit the task via sendSubscribe so it starts asynchronously.
68
+ # We only need the task ID from the first event; the SSE thread then drains
69
+ # silently in the background while we watch the webhook instead.
70
+ # ---------------------------------------------------------------------------
71
+ puts
72
+ puts "Submitting task via tasks/sendSubscribe…"
73
+
74
+ task_id = nil
75
+ id_mutex = Mutex.new
76
+ sse_thread = Thread.new do
77
+ A2A.sse_client(url: A2A_URL).send_subscribe(
78
+ message: A2A::Models::Message.user("run push demo")
79
+ ) do |event|
80
+ case event
81
+ when A2A::Models::TaskStatusUpdateEvent
82
+ id_mutex.synchronize { task_id ||= event.task_id }
83
+ end
84
+ end
85
+ rescue => e
86
+ puts " SSE error: #{e.message}"
87
+ end
88
+
89
+ # Wait until we have the task ID (arrives with the first working event).
90
+ loop do
91
+ sleep 0.05
92
+ break if id_mutex.synchronize { task_id }
93
+ end
94
+
95
+ puts " Task started: id=#{task_id[0, 8]}"
96
+ puts
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Register the webhook via tasks/pushNotification/set
100
+ # ---------------------------------------------------------------------------
101
+ puts "Registering webhook via tasks/pushNotification/set…"
102
+ base_client.send(:rpc_call, "tasks/pushNotification/set", {
103
+ "id" => task_id,
104
+ "pushNotificationConfig" => { "webhookUrl" => WEBHOOK_URL }
105
+ })
106
+ puts " Webhook registered: #{WEBHOOK_URL}"
107
+ puts
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Confirm storage via tasks/pushNotification/get and list
111
+ # ---------------------------------------------------------------------------
112
+ get_result = base_client.send(:rpc_call, "tasks/pushNotification/get", { "id" => task_id })
113
+ list_result = base_client.send(:rpc_call, "tasks/pushNotification/list", {})
114
+
115
+ puts "tasks/pushNotification/get → webhookUrl=#{get_result&.dig("pushNotificationConfig", "webhookUrl")}"
116
+ puts "tasks/pushNotification/list → #{list_result.length} config(s) registered"
117
+ divider
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Watch push deliveries arrive from the server.
121
+ # The server posts after each step; we print each payload as it lands.
122
+ # Stop when we see a final=true delivery.
123
+ # ---------------------------------------------------------------------------
124
+ puts
125
+ puts "Watching webhook for incoming push notifications…"
126
+ puts "(the client has NO open SSE connection — all updates arrive out-of-band)"
127
+ puts
128
+
129
+ received = []
130
+ loop do
131
+ payload = webhook_queue.pop
132
+ received << payload
133
+
134
+ status = payload.dig("status", "state") || "unknown"
135
+ message = payload.dig("status", "message")
136
+ final = payload["final"]
137
+
138
+ line = " push received → state=#{status}"
139
+ line += " (#{message})" if message
140
+ line += " [FINAL]" if final
141
+ puts line
142
+
143
+ break if final
144
+ end
145
+
146
+ puts
147
+ divider
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Delete the push config and confirm it is gone
151
+ # ---------------------------------------------------------------------------
152
+ puts
153
+ puts "Calling tasks/pushNotification/delete…"
154
+ base_client.send(:rpc_call, "tasks/pushNotification/delete", { "id" => task_id })
155
+
156
+ list_after = base_client.send(:rpc_call, "tasks/pushNotification/list", {})
157
+ puts "tasks/pushNotification/list → #{list_after.length} config(s) remaining"
158
+ puts
159
+
160
+ # Wait for the SSE thread to close naturally now that the task is complete.
161
+ sse_thread.join(5)
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Summary
165
+ # ---------------------------------------------------------------------------
166
+ divider
167
+ puts
168
+ puts "=== Summary ==="
169
+ puts " Push notifications received : #{received.length}"
170
+ puts " States delivered : #{received.map { |p| p.dig("status", "state") }.join(" → ")}"
171
+ puts " Final delivery seen : #{received.any? { |p| p["final"] } ? 'yes' : 'no'}"
172
+ puts " Config cleaned up : #{list_after.empty? ? 'yes' : 'no'}"
173
+ puts
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Verification
177
+ # ---------------------------------------------------------------------------
178
+ puts "=== Verification ==="
179
+ states = received.map { |p| p.dig("status", "state") }
180
+ last_state = states.last
181
+
182
+ push_ok = received.length >= 2
183
+ final_ok = received.last&.fetch("final", false)
184
+ state_ok = last_state == "completed"
185
+ cleanup_ok = list_after.empty?
186
+
187
+ puts " Push notifications received (≥2) : #{push_ok ? 'PASS' : 'FAIL'}"
188
+ puts " Final push delivery seen : #{final_ok ? 'PASS' : 'FAIL'}"
189
+ puts " Final state is completed : #{state_ok ? 'PASS' : 'FAIL'}"
190
+ puts " Config deleted successfully : #{cleanup_ok ? 'PASS' : 'FAIL'}"
191
+ puts
192
+ puts(push_ok && final_ok && state_ok && cleanup_ok ? "All assertions passed." : "One or more assertions failed.")
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/06_push_notifications/server.rb
5
+ #
6
+ # Demonstrates push notifications: the server delivers out-of-band HTTP POST
7
+ # payloads to a client-registered webhook URL as a task progresses, without
8
+ # the client needing to hold an open SSE connection.
9
+ #
10
+ # The executor holds shared references to the push_sender and push_config_store
11
+ # so it can deliver after each state change. The same push_config_store is
12
+ # passed to Server::Base so the App's pushNotification/* RPC methods read and
13
+ # write the same store.
14
+
15
+ require_relative "../common_config"
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Executor — 5-step pipeline; delivers a push notification after each step.
19
+ # ---------------------------------------------------------------------------
20
+ class PushExecutor < A2A::Server::AgentExecutor
21
+ STEPS = 5
22
+ STEP_SEC = 2.0
23
+
24
+ def initialize(push_sender:, push_config_store:)
25
+ @push_sender = push_sender
26
+ @push_config_store = push_config_store
27
+ end
28
+
29
+ def call(ctx)
30
+ ctx.task.start!
31
+ ctx.emit_status
32
+ push_status(ctx)
33
+
34
+ STEPS.times do |i|
35
+ sleep STEP_SEC
36
+ return if ctx.task.terminal?
37
+
38
+ ctx.task.status = A2A::Models::TaskStatus.new(
39
+ state: A2A::Models::Types::TaskState::WORKING,
40
+ message: "Step #{i + 1}/#{STEPS} complete"
41
+ )
42
+ ctx.emit_status
43
+ push_status(ctx)
44
+ end
45
+
46
+ return if ctx.task.terminal?
47
+
48
+ ctx.task.complete!(artifacts: [
49
+ A2A::Models::Artifact.new(
50
+ name: "result",
51
+ parts: [A2A::Models::Part.text("All #{STEPS} steps finished for task #{ctx.task.id[0, 8]}")]
52
+ )
53
+ ])
54
+ ctx.emit_status(final: true)
55
+ push_status(ctx, final: true)
56
+ end
57
+
58
+ private
59
+
60
+ def push_status(ctx, final: false)
61
+ config = @push_config_store.get(ctx.task.id)
62
+ return unless config
63
+
64
+ event = A2A::Models::TaskStatusUpdateEvent.new(
65
+ task_id: ctx.task.id,
66
+ context_id: ctx.task.context_id,
67
+ status: ctx.task.status,
68
+ final: final
69
+ )
70
+ @push_sender.deliver(config, event)
71
+ end
72
+ end
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Shared push infrastructure — the executor and the App both reference these
76
+ # so that RPC-registered configs are visible to the executor at delivery time.
77
+ # ---------------------------------------------------------------------------
78
+ push_config_store = A2A::Server::PushConfigStore.new
79
+ push_sender = A2A::Server::PushSender.new
80
+ executor = PushExecutor.new(
81
+ push_sender: push_sender,
82
+ push_config_store: push_config_store
83
+ )
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Agent card — push_notifications: true is required for the RPC methods
87
+ # to be accepted by the server.
88
+ # ---------------------------------------------------------------------------
89
+ card = A2A::Models::AgentCard.new(
90
+ name: "PushAgent",
91
+ version: "1.0",
92
+ description: "Demonstrates out-of-band push notification delivery",
93
+ capabilities: A2A::Models::AgentCapabilities.new(
94
+ streaming: true,
95
+ push_notifications: true
96
+ ),
97
+ skills: [
98
+ A2A::Models::AgentSkill.new(
99
+ name: "push_demo",
100
+ description: "Runs a 5-step task and pushes a status update after each step"
101
+ )
102
+ ],
103
+ interfaces: [
104
+ A2A::Models::AgentInterface.new(
105
+ type: "json-rpc",
106
+ url: "http://localhost:9292",
107
+ version: "1.0"
108
+ )
109
+ ]
110
+ )
111
+
112
+ puts "Starting PushAgent on http://localhost:9292"
113
+ puts " push_notifications: true"
114
+ puts " #{PushExecutor::STEPS} steps × #{PushExecutor::STEP_SEC}s = ~#{(PushExecutor::STEPS * PushExecutor::STEP_SEC).to_i}s per task"
115
+ puts "Press Ctrl-C to stop."
116
+ puts
117
+
118
+ A2A.server(
119
+ agent_card: card,
120
+ executor: executor,
121
+ push_sender: push_sender,
122
+ push_config_store: push_config_store
123
+ ).run
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/07_agent_chaining/client.rb
5
+ #
6
+ # Start the server first:
7
+ # bundle exec ruby examples/07_agent_chaining/server.rb
8
+ #
9
+ # What this demo shows:
10
+ # 1. Three agents are discoverable via their individual agent cards.
11
+ # 2. The client calls only /pipeline — it has no knowledge of the other agents.
12
+ # 3. PipelineAgent internally calls /reverse then /shout via A2A.client,
13
+ # demonstrating agent-to-agent chaining over the A2A protocol.
14
+ # 4. The result exposes all three stages so the chain is visible.
15
+ # 5. The sub-agents are also called directly to confirm they work standalone.
16
+
17
+ require_relative "../common_config"
18
+
19
+ BASE_URL = "http://localhost:9292"
20
+ REVERSE_URL = "#{BASE_URL}/reverse"
21
+ SHOUT_URL = "#{BASE_URL}/shout"
22
+ PIPELINE_URL = "#{BASE_URL}/pipeline"
23
+
24
+ def divider = puts("─" * 60)
25
+
26
+ def artifact_text(task)
27
+ task.artifacts&.first&.parts&.first&.text || "(no artifact)"
28
+ end
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Discover all three agents
32
+ # ---------------------------------------------------------------------------
33
+ puts
34
+ puts "=== Agent Discovery ==="
35
+ [
36
+ ["ReverseAgent", REVERSE_URL],
37
+ ["ShoutAgent", SHOUT_URL],
38
+ ["PipelineAgent", PIPELINE_URL]
39
+ ].each do |expected_name, url|
40
+ card = A2A.client(url: url).agent_card
41
+ puts " #{url.ljust(32)} → #{card.name}: #{card.description}"
42
+ end
43
+ puts
44
+ divider
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Call the pipeline with several inputs — client speaks only to /pipeline
48
+ # ---------------------------------------------------------------------------
49
+ pipeline = A2A.client(url: PIPELINE_URL)
50
+
51
+ inputs = [
52
+ "the quick brown fox jumps over the lazy dog",
53
+ "agent to agent communication is the future",
54
+ "simple is better than complex"
55
+ ]
56
+
57
+ puts
58
+ puts "=== Pipeline calls (client speaks only to /pipeline) ==="
59
+ puts
60
+
61
+ results = inputs.map do |text|
62
+ task = pipeline.send_task(message: A2A::Models::Message.user(text))
63
+ output = artifact_text(task)
64
+ puts "Input: #{text}"
65
+ puts output.lines.map { |l| " #{l}" }.join
66
+ puts
67
+ { input: text, output: output, state: task.status.state }
68
+ end
69
+
70
+ divider
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Call sub-agents directly to show they work standalone
74
+ # ---------------------------------------------------------------------------
75
+ puts
76
+ puts "=== Sub-agents called directly (verification) ==="
77
+ puts
78
+
79
+ sample = "hello world from A2A"
80
+
81
+ reverse_task = A2A.client(url: REVERSE_URL).send_task(
82
+ message: A2A::Models::Message.user(sample)
83
+ )
84
+ shout_task = A2A.client(url: SHOUT_URL).send_task(
85
+ message: A2A::Models::Message.user(sample)
86
+ )
87
+
88
+ puts " Input: #{sample}"
89
+ puts " /reverse result: #{artifact_text(reverse_task)}"
90
+ puts " /shout result: #{artifact_text(shout_task)}"
91
+ puts
92
+
93
+ divider
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Verification
97
+ # ---------------------------------------------------------------------------
98
+ puts
99
+ puts "=== Verification ==="
100
+
101
+ all_completed = results.all? { |r| r[:state] == "completed" }
102
+
103
+ pipeline_correct = results.all? do |r|
104
+ words = r[:input].split
105
+ reversed = words.reverse.join(" ")
106
+ shouted = reversed.upcase
107
+ r[:output].include?(shouted)
108
+ end
109
+
110
+ standalone_reverse_ok = artifact_text(reverse_task) == sample.split.reverse.join(" ")
111
+ standalone_shout_ok = artifact_text(shout_task) == sample.upcase
112
+
113
+ puts " All pipeline tasks completed : #{all_completed ? 'PASS' : 'FAIL'}"
114
+ puts " Pipeline output matches chain : #{pipeline_correct ? 'PASS' : 'FAIL'}"
115
+ puts " ReverseAgent standalone correct : #{standalone_reverse_ok ? 'PASS' : 'FAIL'}"
116
+ puts " ShoutAgent standalone correct : #{standalone_shout_ok ? 'PASS' : 'FAIL'}"
117
+ puts
118
+
119
+ all_ok = all_completed && pipeline_correct && standalone_reverse_ok && standalone_shout_ok
120
+ puts(all_ok ? "All assertions passed." : "One or more assertions failed.")
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/07_agent_chaining/server.rb
5
+ #
6
+ # Three agents on one port via A2A.multi_server:
7
+ #
8
+ # /reverse — reverses the word order of the input text
9
+ # /shout — uppercases the input text
10
+ # /pipeline — calls /reverse then /shout and returns all three stages
11
+ #
12
+ # The client only ever speaks to /pipeline. The chaining between agents
13
+ # happens entirely inside PipelineExecutor using A2A.client — the same
14
+ # client interface any external caller would use.
15
+
16
+ require_relative "../common_config"
17
+
18
+ BASE_URL = "http://localhost:9292"
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # ReverseExecutor — reverses the word order of the input.
22
+ # ---------------------------------------------------------------------------
23
+ class ReverseExecutor < A2A::Server::AgentExecutor
24
+ def call(ctx)
25
+ input = ctx.message.text_content.strip
26
+ reversed = input.split.reverse.join(" ")
27
+ ctx.task.complete!(artifacts: [
28
+ A2A::Models::Artifact.new(
29
+ name: "reversed",
30
+ parts: [A2A::Models::Part.text(reversed)]
31
+ )
32
+ ])
33
+ end
34
+ end
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # ShoutExecutor — uppercases the input.
38
+ # ---------------------------------------------------------------------------
39
+ class ShoutExecutor < A2A::Server::AgentExecutor
40
+ def call(ctx)
41
+ input = ctx.message.text_content.strip
42
+ shouted = input.upcase
43
+ ctx.task.complete!(artifacts: [
44
+ A2A::Models::Artifact.new(
45
+ name: "shouted",
46
+ parts: [A2A::Models::Part.text(shouted)]
47
+ )
48
+ ])
49
+ end
50
+ end
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # PipelineExecutor — orchestrates the other two agents via A2A.client,
54
+ # then composes a result showing every stage.
55
+ # ---------------------------------------------------------------------------
56
+ class PipelineExecutor < A2A::Server::AgentExecutor
57
+ def initialize(base_url:)
58
+ @reverse_client = A2A.client(url: "#{base_url}/reverse")
59
+ @shout_client = A2A.client(url: "#{base_url}/shout")
60
+ end
61
+
62
+ def call(ctx)
63
+ input = ctx.message.text_content.strip
64
+
65
+ # Stage 1: call the ReverseAgent
66
+ reversed = @reverse_client
67
+ .send_task(message: A2A::Models::Message.user(input))
68
+ .artifacts.first&.parts&.first&.text || input
69
+
70
+ # Stage 2: feed the reversed text to the ShoutAgent
71
+ shouted = @shout_client
72
+ .send_task(message: A2A::Models::Message.user(reversed))
73
+ .artifacts.first&.parts&.first&.text || reversed
74
+
75
+ result = <<~RESULT.strip
76
+ Input: #{input}
77
+ Reversed: #{reversed}
78
+ Shouted: #{shouted}
79
+ RESULT
80
+
81
+ ctx.task.complete!(artifacts: [
82
+ A2A::Models::Artifact.new(
83
+ name: "pipeline_result",
84
+ parts: [A2A::Models::Part.text(result)]
85
+ )
86
+ ])
87
+ end
88
+ end
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Agent cards
92
+ # ---------------------------------------------------------------------------
93
+ def make_card(name:, description:, skill:, path:)
94
+ A2A::Models::AgentCard.new(
95
+ name: name,
96
+ version: "1.0",
97
+ description: description,
98
+ capabilities: A2A::Models::AgentCapabilities.new,
99
+ skills: [
100
+ A2A::Models::AgentSkill.new(name: skill, description: description)
101
+ ],
102
+ interfaces: [
103
+ A2A::Models::AgentInterface.new(
104
+ type: "json-rpc",
105
+ url: "#{BASE_URL}#{path}",
106
+ version: "1.0"
107
+ )
108
+ ]
109
+ )
110
+ end
111
+
112
+ reverse_card = make_card(
113
+ name: "ReverseAgent",
114
+ description: "Reverses the word order of input text",
115
+ skill: "reverse",
116
+ path: "/reverse"
117
+ )
118
+
119
+ shout_card = make_card(
120
+ name: "ShoutAgent",
121
+ description: "Uppercases input text",
122
+ skill: "shout",
123
+ path: "/shout"
124
+ )
125
+
126
+ pipeline_card = make_card(
127
+ name: "PipelineAgent",
128
+ description: "Chains ReverseAgent then ShoutAgent and returns all three stages",
129
+ skill: "pipeline",
130
+ path: "/pipeline"
131
+ )
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Start the multi-agent server
135
+ # ---------------------------------------------------------------------------
136
+ puts "Starting multi-agent server on #{BASE_URL}"
137
+ puts " /reverse → ReverseAgent"
138
+ puts " /shout → ShoutAgent"
139
+ puts " /pipeline → PipelineAgent (chains the other two)"
140
+ puts "Press Ctrl-C to stop."
141
+ puts
142
+
143
+ A2A.multi_server(
144
+ agents: {
145
+ "/reverse" => { agent_card: reverse_card, executor: ReverseExecutor.new },
146
+ "/shout" => { agent_card: shout_card, executor: ShoutExecutor.new },
147
+ "/pipeline" => { agent_card: pipeline_card, executor: PipelineExecutor.new(base_url: BASE_URL) }
148
+ },
149
+ port: 9292
150
+ ).run