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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +78 -38
- data/compare_agent2agent.md +460 -0
- data/docs/api/client/index.md +19 -0
- data/docs/api/index.md +4 -3
- data/docs/api/models/index.md +13 -11
- data/docs/api/server/index.md +42 -10
- data/docs/api/storage/index.md +0 -1
- data/docs/architecture/index.md +17 -15
- data/docs/architecture/protocol.md +16 -1
- data/docs/assets/images/simple_a2a.jpg +0 -0
- data/docs/examples/agent-chaining.md +107 -0
- data/docs/examples/auth-headers.md +105 -0
- data/docs/examples/cancellation.md +105 -0
- data/docs/examples/index.md +123 -52
- data/docs/examples/interrupted-states.md +114 -0
- data/docs/examples/multipart.md +103 -0
- data/docs/examples/push-notifications.md +117 -0
- data/docs/examples/resubscribe.md +129 -0
- data/docs/examples/sqlite-storage.md +131 -0
- data/docs/examples/streaming.md +1 -4
- data/docs/guides/push-notifications.md +4 -1
- data/docs/guides/streaming.md +34 -5
- data/docs/index.md +55 -27
- data/examples/04_resubscribe/client.rb +140 -0
- data/examples/04_resubscribe/server.rb +75 -0
- data/examples/05_cancellation/client.rb +150 -0
- data/examples/05_cancellation/server.rb +77 -0
- data/examples/06_push_notifications/client.rb +192 -0
- data/examples/06_push_notifications/server.rb +123 -0
- data/examples/07_agent_chaining/client.rb +120 -0
- data/examples/07_agent_chaining/server.rb +150 -0
- data/examples/08_interrupted_states/client.rb +148 -0
- data/examples/08_interrupted_states/server.rb +142 -0
- data/examples/09_multipart/client.rb +117 -0
- data/examples/09_multipart/server.rb +97 -0
- data/examples/10_auth_headers/client.rb +92 -0
- data/examples/10_auth_headers/server.rb +98 -0
- data/examples/11_sqlite_storage/Brewfile +1 -0
- data/examples/11_sqlite_storage/Gemfile +9 -0
- data/examples/11_sqlite_storage/client.rb +114 -0
- data/examples/11_sqlite_storage/run +154 -0
- data/examples/11_sqlite_storage/server.rb +131 -0
- data/examples/README.md +384 -0
- data/lib/simple_a2a/client/sse.rb +15 -0
- data/lib/simple_a2a/server/app.rb +131 -45
- data/lib/simple_a2a/server/base.rb +19 -17
- data/lib/simple_a2a/server/broadcast_registry.rb +24 -0
- data/lib/simple_a2a/server/multi_agent.rb +1 -1
- data/lib/simple_a2a/server/push_config_store.rb +29 -0
- data/lib/simple_a2a/server/push_sender.rb +1 -0
- data/lib/simple_a2a/server/task_broadcast.rb +46 -0
- data/lib/simple_a2a/version.rb +1 -1
- metadata +38 -20
- 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
|