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,148 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/08_interrupted_states/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/08_interrupted_states/server.rb
|
|
8
|
+
#
|
|
9
|
+
# What this demo shows:
|
|
10
|
+
#
|
|
11
|
+
# Scenario A — input_required (OrderAgent):
|
|
12
|
+
# Turn 1: client sends an initial request → agent pauses with input_required
|
|
13
|
+
# and a question in status.message
|
|
14
|
+
# Turn 2: client reads the question, sends the answer with the same
|
|
15
|
+
# context_id → agent completes the task
|
|
16
|
+
#
|
|
17
|
+
# Scenario B — auth_required (VaultAgent):
|
|
18
|
+
# Turn 1: client requests protected data → agent pauses with auth_required
|
|
19
|
+
# Turn 2: client sends the wrong token → agent stays in auth_required
|
|
20
|
+
# Turn 3: client sends the correct token → agent completes the task
|
|
21
|
+
#
|
|
22
|
+
# Each turn is a separate tasks/send call. The agents use message.context_id
|
|
23
|
+
# to thread the conversation across calls.
|
|
24
|
+
|
|
25
|
+
require_relative "../common_config"
|
|
26
|
+
|
|
27
|
+
BASE_URL = "http://localhost:9292"
|
|
28
|
+
ORDER_URL = "#{BASE_URL}/order"
|
|
29
|
+
VAULT_URL = "#{BASE_URL}/vault"
|
|
30
|
+
|
|
31
|
+
def divider = puts("─" * 60)
|
|
32
|
+
|
|
33
|
+
def msg(text, context_id:)
|
|
34
|
+
A2A::Models::Message.new(
|
|
35
|
+
role: A2A::Models::Types::Role::USER,
|
|
36
|
+
parts: [A2A::Models::Part.text(text)],
|
|
37
|
+
context_id: context_id
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def show_task(label, task)
|
|
42
|
+
state = task.status.state
|
|
43
|
+
message = task.status.message
|
|
44
|
+
artifact = task.artifacts&.first&.parts&.first&.text
|
|
45
|
+
|
|
46
|
+
puts " [#{label}] state=#{state}"
|
|
47
|
+
puts " agent says: #{message}" if message
|
|
48
|
+
puts " result: #{artifact}" if artifact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Scenario A — input_required
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
puts
|
|
55
|
+
puts "=== Scenario A: input_required (OrderAgent) ==="
|
|
56
|
+
puts
|
|
57
|
+
|
|
58
|
+
order = A2A.client(url: ORDER_URL)
|
|
59
|
+
conv_a = SecureRandom.uuid
|
|
60
|
+
|
|
61
|
+
puts " context_id: #{conv_a[0, 8]}…"
|
|
62
|
+
puts
|
|
63
|
+
|
|
64
|
+
# Turn 1 — agent doesn't know what to make yet
|
|
65
|
+
task_a1 = order.send_task(message: msg("I'd like to place an order", context_id: conv_a))
|
|
66
|
+
show_task("turn 1", task_a1)
|
|
67
|
+
|
|
68
|
+
abort "Expected input_required, got #{task_a1.status.state}" unless task_a1.status.state == "input_required"
|
|
69
|
+
|
|
70
|
+
puts
|
|
71
|
+
puts " Client reads the question and answers: 'pasta'"
|
|
72
|
+
puts
|
|
73
|
+
|
|
74
|
+
# Turn 2 — client answers with the same context_id
|
|
75
|
+
task_a2 = order.send_task(message: msg("pasta", context_id: conv_a))
|
|
76
|
+
show_task("turn 2", task_a2)
|
|
77
|
+
|
|
78
|
+
divider
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Scenario B — auth_required (with a wrong token first)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
puts
|
|
84
|
+
puts "=== Scenario B: auth_required (VaultAgent) ==="
|
|
85
|
+
puts
|
|
86
|
+
|
|
87
|
+
vault = A2A.client(url: VAULT_URL)
|
|
88
|
+
conv_b = SecureRandom.uuid
|
|
89
|
+
|
|
90
|
+
puts " context_id: #{conv_b[0, 8]}…"
|
|
91
|
+
puts
|
|
92
|
+
|
|
93
|
+
# Turn 1 — agent demands a token
|
|
94
|
+
task_b1 = vault.send_task(message: msg("show me the secret data", context_id: conv_b))
|
|
95
|
+
show_task("turn 1", task_b1)
|
|
96
|
+
|
|
97
|
+
abort "Expected auth_required, got #{task_b1.status.state}" unless task_b1.status.state == "auth_required"
|
|
98
|
+
|
|
99
|
+
puts
|
|
100
|
+
puts " Client sends the wrong token: 'wrong-token'"
|
|
101
|
+
puts
|
|
102
|
+
|
|
103
|
+
# Turn 2 — wrong token; agent stays blocked
|
|
104
|
+
task_b2 = vault.send_task(message: msg("wrong-token", context_id: conv_b))
|
|
105
|
+
show_task("turn 2", task_b2)
|
|
106
|
+
|
|
107
|
+
abort "Expected auth_required, got #{task_b2.status.state}" unless task_b2.status.state == "auth_required"
|
|
108
|
+
|
|
109
|
+
puts
|
|
110
|
+
puts " Client sends the correct token: 'open-sesame'"
|
|
111
|
+
puts
|
|
112
|
+
|
|
113
|
+
# Turn 3 — correct token; agent unlocks
|
|
114
|
+
task_b3 = vault.send_task(message: msg("open-sesame", context_id: conv_b))
|
|
115
|
+
show_task("turn 3", task_b3)
|
|
116
|
+
|
|
117
|
+
divider
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Verification
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
puts
|
|
123
|
+
puts "=== Verification ==="
|
|
124
|
+
|
|
125
|
+
a_paused = task_a1.status.state == "input_required"
|
|
126
|
+
a_asked = task_a1.status.message&.include?("Options:")
|
|
127
|
+
a_complete = task_a2.status.state == "completed"
|
|
128
|
+
a_artifact = task_a2.artifacts&.first&.parts&.first&.text&.include?("pasta")
|
|
129
|
+
|
|
130
|
+
b_blocked1 = task_b1.status.state == "auth_required"
|
|
131
|
+
b_blocked2 = task_b2.status.state == "auth_required"
|
|
132
|
+
b_complete = task_b3.status.state == "completed"
|
|
133
|
+
b_artifact = task_b3.artifacts&.first&.parts&.first&.text&.include?("treasure")
|
|
134
|
+
|
|
135
|
+
puts " [order] turn 1 paused with input_required : #{a_paused ? 'PASS' : 'FAIL'}"
|
|
136
|
+
puts " [order] turn 1 included a question : #{a_asked ? 'PASS' : 'FAIL'}"
|
|
137
|
+
puts " [order] turn 2 completed after answer : #{a_complete ? 'PASS' : 'FAIL'}"
|
|
138
|
+
puts " [order] turn 2 artifact mentions pasta : #{a_artifact ? 'PASS' : 'FAIL'}"
|
|
139
|
+
puts
|
|
140
|
+
puts " [vault] turn 1 paused with auth_required : #{b_blocked1 ? 'PASS' : 'FAIL'}"
|
|
141
|
+
puts " [vault] turn 2 stayed blocked (bad token) : #{b_blocked2 ? 'PASS' : 'FAIL'}"
|
|
142
|
+
puts " [vault] turn 3 completed after good token : #{b_complete ? 'PASS' : 'FAIL'}"
|
|
143
|
+
puts " [vault] turn 3 artifact contains secret : #{b_artifact ? 'PASS' : 'FAIL'}"
|
|
144
|
+
puts
|
|
145
|
+
|
|
146
|
+
all_ok = a_paused && a_asked && a_complete && a_artifact &&
|
|
147
|
+
b_blocked1 && b_blocked2 && b_complete && b_artifact
|
|
148
|
+
puts(all_ok ? "All assertions passed." : "One or more assertions failed.")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/08_interrupted_states/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Two agents on one port demonstrating the two interrupted task states:
|
|
7
|
+
#
|
|
8
|
+
# /order — uses input_required to ask the user what they want before
|
|
9
|
+
# completing the order
|
|
10
|
+
# /vault — uses auth_required to demand a token before revealing data
|
|
11
|
+
#
|
|
12
|
+
# Each turn from the client is a separate tasks/send call. The executors
|
|
13
|
+
# use message.context_id as a conversation key to distinguish first turns
|
|
14
|
+
# from follow-ups.
|
|
15
|
+
|
|
16
|
+
require_relative "../common_config"
|
|
17
|
+
|
|
18
|
+
BASE_URL = "http://localhost:9292"
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# OrderExecutor — demonstrates input_required.
|
|
22
|
+
#
|
|
23
|
+
# Turn 1: any message → input_required: "What would you like?"
|
|
24
|
+
# Turn 2: message containing a menu item → completed with confirmation
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
class OrderExecutor < A2A::Server::AgentExecutor
|
|
27
|
+
MENU = %w[pizza pasta salad].freeze
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@pending = {}
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(ctx)
|
|
35
|
+
conv_id = ctx.message.context_id
|
|
36
|
+
input = ctx.message.text_content.strip.downcase
|
|
37
|
+
state = @mutex.synchronize { @pending[conv_id] }
|
|
38
|
+
|
|
39
|
+
if state.nil?
|
|
40
|
+
@mutex.synchronize { @pending[conv_id] = :awaiting_item }
|
|
41
|
+
ctx.task.require_input!(
|
|
42
|
+
message: "What would you like to order? Options: #{MENU.join(', ')}"
|
|
43
|
+
)
|
|
44
|
+
else
|
|
45
|
+
@mutex.synchronize { @pending.delete(conv_id) }
|
|
46
|
+
item = MENU.find { |m| input.include?(m) }
|
|
47
|
+
|
|
48
|
+
if item
|
|
49
|
+
ctx.task.complete!(artifacts: [
|
|
50
|
+
A2A::Models::Artifact.new(
|
|
51
|
+
name: "confirmation",
|
|
52
|
+
parts: [A2A::Models::Part.text("Order confirmed: #{item}! It will be ready in 20 minutes.")]
|
|
53
|
+
)
|
|
54
|
+
])
|
|
55
|
+
else
|
|
56
|
+
ctx.task.fail!(message: "Unknown item '#{input}'. Please choose from: #{MENU.join(', ')}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# VaultExecutor — demonstrates auth_required.
|
|
64
|
+
#
|
|
65
|
+
# Turn 1: any message → auth_required: "Provide your access token"
|
|
66
|
+
# Turn 2: correct token → completed with secret data
|
|
67
|
+
# wrong token → auth_required again (stays blocked)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
class VaultExecutor < A2A::Server::AgentExecutor
|
|
70
|
+
SECRET_TOKEN = "open-sesame"
|
|
71
|
+
SECRET_DATA = "The treasure is buried under the old oak tree at coordinates 48.8566°N, 2.3522°E."
|
|
72
|
+
|
|
73
|
+
def initialize
|
|
74
|
+
@pending = {}
|
|
75
|
+
@mutex = Mutex.new
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(ctx)
|
|
79
|
+
conv_id = ctx.message.context_id
|
|
80
|
+
input = ctx.message.text_content.strip
|
|
81
|
+
state = @mutex.synchronize { @pending[conv_id] }
|
|
82
|
+
|
|
83
|
+
if state.nil?
|
|
84
|
+
@mutex.synchronize { @pending[conv_id] = :awaiting_token }
|
|
85
|
+
ctx.task.require_auth!(message: "Access restricted. Provide your token to continue.")
|
|
86
|
+
elsif input == SECRET_TOKEN
|
|
87
|
+
@mutex.synchronize { @pending.delete(conv_id) }
|
|
88
|
+
ctx.task.complete!(artifacts: [
|
|
89
|
+
A2A::Models::Artifact.new(
|
|
90
|
+
name: "secret",
|
|
91
|
+
parts: [A2A::Models::Part.text(SECRET_DATA)]
|
|
92
|
+
)
|
|
93
|
+
])
|
|
94
|
+
else
|
|
95
|
+
ctx.task.require_auth!(message: "Invalid token. Try again.")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Agent cards
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
def make_card(name:, description:, skill:, path:)
|
|
104
|
+
A2A::Models::AgentCard.new(
|
|
105
|
+
name: name,
|
|
106
|
+
version: "1.0",
|
|
107
|
+
description: description,
|
|
108
|
+
capabilities: A2A::Models::AgentCapabilities.new,
|
|
109
|
+
skills: [A2A::Models::AgentSkill.new(name: skill, description: description)],
|
|
110
|
+
interfaces: [A2A::Models::AgentInterface.new(
|
|
111
|
+
type: "json-rpc", url: "#{BASE_URL}#{path}", version: "1.0"
|
|
112
|
+
)]
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
order_card = make_card(
|
|
117
|
+
name: "OrderAgent",
|
|
118
|
+
description: "Takes food orders — pauses with input_required to ask what the user wants",
|
|
119
|
+
skill: "order",
|
|
120
|
+
path: "/order"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
vault_card = make_card(
|
|
124
|
+
name: "VaultAgent",
|
|
125
|
+
description: "Guards secret data — pauses with auth_required until a valid token is provided",
|
|
126
|
+
skill: "vault",
|
|
127
|
+
path: "/vault"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
puts "Starting interrupted-states server on #{BASE_URL}"
|
|
131
|
+
puts " /order → OrderAgent (demonstrates input_required)"
|
|
132
|
+
puts " /vault → VaultAgent (demonstrates auth_required)"
|
|
133
|
+
puts "Press Ctrl-C to stop."
|
|
134
|
+
puts
|
|
135
|
+
|
|
136
|
+
A2A.multi_server(
|
|
137
|
+
agents: {
|
|
138
|
+
"/order" => { agent_card: order_card, executor: OrderExecutor.new },
|
|
139
|
+
"/vault" => { agent_card: vault_card, executor: VaultExecutor.new }
|
|
140
|
+
},
|
|
141
|
+
port: 9292
|
|
142
|
+
).run
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/09_multipart/client.rb [topic]
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/09_multipart/server.rb
|
|
8
|
+
#
|
|
9
|
+
# What this demo shows:
|
|
10
|
+
# A single artifact can carry multiple parts of different types.
|
|
11
|
+
# The client inspects each part using the predicate methods (text?,
|
|
12
|
+
# json?, raw?, url?) and processes each one appropriately:
|
|
13
|
+
#
|
|
14
|
+
# Part.text → print the prose
|
|
15
|
+
# Part.json → pretty-print the hash
|
|
16
|
+
# Part.binary → decode from base64, display as text or byte count
|
|
17
|
+
# Part.from_url → display the URL reference
|
|
18
|
+
|
|
19
|
+
require_relative "../common_config"
|
|
20
|
+
require "json"
|
|
21
|
+
|
|
22
|
+
URL = "http://localhost:9292"
|
|
23
|
+
topic = ARGV.first || "agent to agent protocol design"
|
|
24
|
+
|
|
25
|
+
def divider = puts("─" * 60)
|
|
26
|
+
def header(title) = puts("\n ── #{title} ──\n")
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Discover the agent
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
client = A2A.client(url: URL)
|
|
32
|
+
card = client.agent_card
|
|
33
|
+
|
|
34
|
+
puts
|
|
35
|
+
puts "=== Agent Card ==="
|
|
36
|
+
puts " Name: #{card.name}"
|
|
37
|
+
puts " Description: #{card.description}"
|
|
38
|
+
puts
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Send the task
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
puts "=== Sending task: #{topic.inspect} ==="
|
|
44
|
+
puts
|
|
45
|
+
|
|
46
|
+
task = client.send_task(message: A2A::Models::Message.user(topic))
|
|
47
|
+
|
|
48
|
+
puts " state: #{task.status.state}"
|
|
49
|
+
puts " artifacts: #{task.artifacts.length}"
|
|
50
|
+
|
|
51
|
+
artifact = task.artifacts.first
|
|
52
|
+
puts " artifact: #{artifact.name} (#{artifact.parts.length} parts)"
|
|
53
|
+
divider
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Inspect each part by type
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
puts
|
|
59
|
+
puts "=== Parts ==="
|
|
60
|
+
|
|
61
|
+
artifact.parts.each_with_index do |part, i|
|
|
62
|
+
puts
|
|
63
|
+
puts " Part #{i + 1} of #{artifact.parts.length}"
|
|
64
|
+
|
|
65
|
+
if part.text?
|
|
66
|
+
header "text (media_type: #{part.media_type})"
|
|
67
|
+
part.text.each_line { |l| puts " #{l}" }
|
|
68
|
+
|
|
69
|
+
elsif part.json?
|
|
70
|
+
header "json (filename: #{part.filename})"
|
|
71
|
+
JSON.pretty_generate(part.data).each_line { |l| puts " #{l}" }
|
|
72
|
+
|
|
73
|
+
elsif part.raw?
|
|
74
|
+
header "binary (media_type: #{part.media_type}, filename: #{part.filename})"
|
|
75
|
+
bytes = part.decoded_bytes
|
|
76
|
+
puts " base64 length : #{part.raw.length} chars"
|
|
77
|
+
puts " decoded bytes : #{bytes.bytesize}"
|
|
78
|
+
puts " content:"
|
|
79
|
+
bytes.force_encoding("UTF-8").each_line { |l| puts " #{l}" }
|
|
80
|
+
|
|
81
|
+
elsif part.url?
|
|
82
|
+
header "url (media_type: #{part.media_type}, filename: #{part.filename})"
|
|
83
|
+
puts " #{part.url}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
divider
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Verification
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
parts = artifact.parts
|
|
93
|
+
|
|
94
|
+
text_part = parts.find(&:text?)
|
|
95
|
+
json_part = parts.find(&:json?)
|
|
96
|
+
binary_part = parts.find(&:raw?)
|
|
97
|
+
url_part = parts.find(&:url?)
|
|
98
|
+
|
|
99
|
+
puts
|
|
100
|
+
puts "=== Verification ==="
|
|
101
|
+
puts " text part present and non-empty : #{(text_part && !text_part.text.empty?) ? 'PASS' : 'FAIL'}"
|
|
102
|
+
puts " json part present with hash : #{(json_part && json_part.data.is_a?(Hash)) ? 'PASS' : 'FAIL'}"
|
|
103
|
+
puts " binary part decodes correctly : #{(binary_part && binary_part.decoded_bytes.bytesize > 0) ? 'PASS' : 'FAIL'}"
|
|
104
|
+
puts " url part is a valid URL : #{(url_part && url_part.url.start_with?("https://")) ? 'PASS' : 'FAIL'}"
|
|
105
|
+
puts " json part topic matches input : #{(json_part && json_part.data["topic"] == topic) ? 'PASS' : 'FAIL'}"
|
|
106
|
+
puts " binary part is valid CSV : #{(binary_part && binary_part.decoded_bytes.include?("rank")) ? 'PASS' : 'FAIL'}"
|
|
107
|
+
puts
|
|
108
|
+
|
|
109
|
+
all_ok = text_part && json_part && binary_part && url_part &&
|
|
110
|
+
!text_part.text.empty? &&
|
|
111
|
+
json_part.data.is_a?(Hash) &&
|
|
112
|
+
binary_part.decoded_bytes.bytesize > 0 &&
|
|
113
|
+
url_part.url.start_with?("https://") &&
|
|
114
|
+
json_part.data["topic"] == topic &&
|
|
115
|
+
binary_part.decoded_bytes.include?("rank")
|
|
116
|
+
|
|
117
|
+
puts(all_ok ? "All assertions passed." : "One or more assertions failed.")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/09_multipart/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates all four Part types in a single artifact:
|
|
7
|
+
#
|
|
8
|
+
# Part.text — plain prose summary
|
|
9
|
+
# Part.json — structured metadata as a Ruby hash
|
|
10
|
+
# Part.binary — raw bytes (a CSV here), base64-encoded in transit
|
|
11
|
+
# Part.from_url — a URL reference to an external resource
|
|
12
|
+
#
|
|
13
|
+
# The agent treats any input as a topic and fabricates a four-part
|
|
14
|
+
# report artifact so the client can exercise every Part type.
|
|
15
|
+
|
|
16
|
+
require_relative "../common_config"
|
|
17
|
+
|
|
18
|
+
class ReportExecutor < A2A::Server::AgentExecutor
|
|
19
|
+
def call(ctx)
|
|
20
|
+
topic = ctx.message.text_content.strip
|
|
21
|
+
topic = "unknown topic" if topic.empty?
|
|
22
|
+
|
|
23
|
+
# Part 1 — prose summary
|
|
24
|
+
summary = A2A::Models::Part.text(<<~TEXT.strip, media_type: "text/plain")
|
|
25
|
+
Report on: #{topic}
|
|
26
|
+
Generated at #{Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}
|
|
27
|
+
|
|
28
|
+
This is a synthetic report for demonstration purposes. In a real agent,
|
|
29
|
+
this section would contain the executive summary of the analysis.
|
|
30
|
+
TEXT
|
|
31
|
+
|
|
32
|
+
# Part 2 — structured JSON metadata
|
|
33
|
+
metadata = A2A::Models::Part.json(
|
|
34
|
+
{
|
|
35
|
+
"topic" => topic,
|
|
36
|
+
"word_count" => topic.split.length,
|
|
37
|
+
"tags" => topic.downcase.split.first(3),
|
|
38
|
+
"confidence" => 0.92,
|
|
39
|
+
"generated" => true
|
|
40
|
+
},
|
|
41
|
+
filename: "metadata.json"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Part 3 — binary blob (a tiny CSV), base64-encoded in transit
|
|
45
|
+
csv_rows = [["rank", "term", "score"]] +
|
|
46
|
+
topic.split.first(5).each_with_index.map { |w, i| [i + 1, w, (0.9 - i * 0.1).round(2)] }
|
|
47
|
+
csv_bytes = csv_rows.map { |row| row.join(",") }.join("\n").b
|
|
48
|
+
|
|
49
|
+
csv_part = A2A::Models::Part.binary(
|
|
50
|
+
csv_bytes,
|
|
51
|
+
media_type: "text/csv",
|
|
52
|
+
filename: "term_scores.csv"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Part 4 — URL reference to a related external resource
|
|
56
|
+
search_url = "https://en.wikipedia.org/wiki/Special:Search?search=#{URI.encode_uri_component(topic)}"
|
|
57
|
+
url_part = A2A::Models::Part.from_url(
|
|
58
|
+
search_url,
|
|
59
|
+
media_type: "text/html",
|
|
60
|
+
filename: "reference.html"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
ctx.task.complete!(artifacts: [
|
|
64
|
+
A2A::Models::Artifact.new(
|
|
65
|
+
name: "report",
|
|
66
|
+
parts: [summary, metadata, csv_part, url_part]
|
|
67
|
+
)
|
|
68
|
+
])
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
card = A2A::Models::AgentCard.new(
|
|
73
|
+
name: "ReportAgent",
|
|
74
|
+
version: "1.0",
|
|
75
|
+
description: "Returns a four-part artifact: text summary, JSON metadata, binary CSV, and a URL reference",
|
|
76
|
+
capabilities: A2A::Models::AgentCapabilities.new,
|
|
77
|
+
skills: [
|
|
78
|
+
A2A::Models::AgentSkill.new(
|
|
79
|
+
name: "report",
|
|
80
|
+
description: "Generates a multi-part report artifact for any topic"
|
|
81
|
+
)
|
|
82
|
+
],
|
|
83
|
+
interfaces: [
|
|
84
|
+
A2A::Models::AgentInterface.new(
|
|
85
|
+
type: "json-rpc",
|
|
86
|
+
url: "http://localhost:9292",
|
|
87
|
+
version: "1.0"
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
puts "Starting ReportAgent on http://localhost:9292"
|
|
93
|
+
puts "Returns a 4-part artifact (text + JSON + binary + URL) for any topic."
|
|
94
|
+
puts "Press Ctrl-C to stop."
|
|
95
|
+
puts
|
|
96
|
+
|
|
97
|
+
A2A.server(agent_card: card, executor: ReportExecutor.new).run
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/10_auth_headers/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/10_auth_headers/server.rb
|
|
8
|
+
#
|
|
9
|
+
# What this demo shows:
|
|
10
|
+
# 1. Agent card discovery (GET) works for both clients — it is public.
|
|
11
|
+
# 2. An unauthenticated client (no headers) is rejected on send_task.
|
|
12
|
+
# 3. An authenticated client (headers: { "Authorization" => "Bearer ..." })
|
|
13
|
+
# succeeds on send_task.
|
|
14
|
+
# 4. The headers: option accepts any key/value pairs, making it suitable
|
|
15
|
+
# for Bearer tokens, API keys, or any custom header scheme.
|
|
16
|
+
|
|
17
|
+
require_relative "../common_config"
|
|
18
|
+
|
|
19
|
+
URL = "http://localhost:9292"
|
|
20
|
+
TOKEN = "super-secret-token"
|
|
21
|
+
|
|
22
|
+
def divider = puts("─" * 60)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Two clients — same URL, different headers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
unauth_client = A2A.client(url: URL)
|
|
28
|
+
auth_client = A2A.client(url: URL, headers: { "Authorization" => "Bearer #{TOKEN}" })
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Agent card — public endpoint, both clients can discover it
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
puts
|
|
34
|
+
puts "=== Agent Card (public — no auth required) ==="
|
|
35
|
+
card = unauth_client.agent_card
|
|
36
|
+
puts " Name: #{card.name}"
|
|
37
|
+
puts " Description: #{card.description}"
|
|
38
|
+
puts
|
|
39
|
+
divider
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Unauthenticated client — rejected on RPC call
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
puts
|
|
45
|
+
puts "=== Unauthenticated client — no Authorization header ==="
|
|
46
|
+
puts
|
|
47
|
+
|
|
48
|
+
unauth_error = nil
|
|
49
|
+
begin
|
|
50
|
+
unauth_client.send_task(message: A2A::Models::Message.user("hello"))
|
|
51
|
+
puts " (unexpected success)"
|
|
52
|
+
rescue A2A::Error => e
|
|
53
|
+
unauth_error = e
|
|
54
|
+
puts " Rejected as expected: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
divider
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Authenticated client — accepted
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
puts
|
|
63
|
+
puts "=== Authenticated client — Authorization: Bearer #{TOKEN} ==="
|
|
64
|
+
puts
|
|
65
|
+
|
|
66
|
+
auth_task = auth_client.send_task(message: A2A::Models::Message.user("hello from authorized client"))
|
|
67
|
+
auth_result = auth_task.artifacts&.first&.parts&.first&.text
|
|
68
|
+
|
|
69
|
+
puts " state: #{auth_task.status.state}"
|
|
70
|
+
puts " result: #{auth_result}"
|
|
71
|
+
|
|
72
|
+
divider
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Verification
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
puts
|
|
78
|
+
puts "=== Verification ==="
|
|
79
|
+
|
|
80
|
+
card_ok = card.name == "SecureAgent"
|
|
81
|
+
rejected_ok = unauth_error&.message&.include?("Unauthorized")
|
|
82
|
+
accepted_ok = auth_task.status.state == "completed"
|
|
83
|
+
result_ok = auth_result&.include?("[authorized]")
|
|
84
|
+
|
|
85
|
+
puts " Agent card discoverable without auth : #{card_ok ? 'PASS' : 'FAIL'}"
|
|
86
|
+
puts " Unauthenticated call rejected : #{rejected_ok ? 'PASS' : 'FAIL'}"
|
|
87
|
+
puts " Authenticated call accepted : #{accepted_ok ? 'PASS' : 'FAIL'}"
|
|
88
|
+
puts " Authenticated result is correct : #{result_ok ? 'PASS' : 'FAIL'}"
|
|
89
|
+
puts
|
|
90
|
+
|
|
91
|
+
all_ok = card_ok && rejected_ok && accepted_ok && result_ok
|
|
92
|
+
puts(all_ok ? "All assertions passed." : "One or more assertions failed.")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/10_auth_headers/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates the headers: option on A2A.client by running a server that
|
|
7
|
+
# enforces Bearer token authentication on all RPC calls (POST requests).
|
|
8
|
+
# Agent card discovery (GET /agentCard) is intentionally left public.
|
|
9
|
+
#
|
|
10
|
+
# The middleware wraps the standard rack_app — no library changes required.
|
|
11
|
+
# Any Rack middleware (OAuth, mTLS, API key, custom headers) can be layered
|
|
12
|
+
# the same way.
|
|
13
|
+
#
|
|
14
|
+
# Valid token: "super-secret-token"
|
|
15
|
+
|
|
16
|
+
require_relative "../common_config"
|
|
17
|
+
|
|
18
|
+
VALID_TOKEN = "super-secret-token"
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Rack middleware — checks Authorization: Bearer <token> on POST requests.
|
|
22
|
+
# Returns a JSON-RPC shaped error so the A2A client can parse it cleanly.
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
class BearerAuthMiddleware
|
|
25
|
+
def initialize(app, token:)
|
|
26
|
+
@app = app
|
|
27
|
+
@token = token
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
if env["REQUEST_METHOD"] == "POST"
|
|
32
|
+
auth = env["HTTP_AUTHORIZATION"].to_s
|
|
33
|
+
unless auth == "Bearer #{@token}"
|
|
34
|
+
body = JSON.generate({
|
|
35
|
+
"jsonrpc" => "2.0",
|
|
36
|
+
"id" => nil,
|
|
37
|
+
"error" => {
|
|
38
|
+
"code" => -32_000,
|
|
39
|
+
"message" => "Unauthorized: valid Bearer token required"
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return [200, { "Content-Type" => "application/json" }, [body]]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@app.call(env)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Executor — echoes the input back, confirming the request was authorized.
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
class SecureEchoExecutor < A2A::Server::AgentExecutor
|
|
54
|
+
def call(ctx)
|
|
55
|
+
input = ctx.message.text_content.strip
|
|
56
|
+
ctx.task.complete!(artifacts: [
|
|
57
|
+
A2A::Models::Artifact.new(
|
|
58
|
+
name: "reply",
|
|
59
|
+
parts: [A2A::Models::Part.text("[authorized] echo: #{input}")]
|
|
60
|
+
)
|
|
61
|
+
])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Agent card and server
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
card = A2A::Models::AgentCard.new(
|
|
69
|
+
name: "SecureAgent",
|
|
70
|
+
version: "1.0",
|
|
71
|
+
description: "Requires a Bearer token on all RPC calls; agent card is public",
|
|
72
|
+
capabilities: A2A::Models::AgentCapabilities.new,
|
|
73
|
+
skills: [
|
|
74
|
+
A2A::Models::AgentSkill.new(
|
|
75
|
+
name: "secure_echo",
|
|
76
|
+
description: "Echoes input — only reachable with a valid Bearer token"
|
|
77
|
+
)
|
|
78
|
+
],
|
|
79
|
+
interfaces: [
|
|
80
|
+
A2A::Models::AgentInterface.new(
|
|
81
|
+
type: "json-rpc",
|
|
82
|
+
url: "http://localhost:9292",
|
|
83
|
+
version: "1.0"
|
|
84
|
+
)
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Build the rack app and wrap it with auth middleware.
|
|
89
|
+
inner_app = A2A.server(agent_card: card, executor: SecureEchoExecutor.new).rack_app
|
|
90
|
+
auth_app = BearerAuthMiddleware.new(inner_app, token: VALID_TOKEN)
|
|
91
|
+
|
|
92
|
+
puts "Starting SecureAgent on http://localhost:9292"
|
|
93
|
+
puts " GET /agentCard — public (no auth required)"
|
|
94
|
+
puts " POST / — requires Authorization: Bearer #{VALID_TOKEN}"
|
|
95
|
+
puts "Press Ctrl-C to stop."
|
|
96
|
+
puts
|
|
97
|
+
|
|
98
|
+
A2A::Server::FalconRunner.new(auth_app, port: 9292).run
|