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,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