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
data/docs/index.md CHANGED
@@ -1,22 +1,67 @@
1
1
  # simple_a2a
2
2
 
3
- A Ruby gem implementing the [Agent2Agent (A2A) protocol](https://a2a-protocol.org/latest/) — an open standard by Google and the Linux Foundation for interoperability between AI agents.
3
+ <div class="grid" markdown>
4
4
 
5
- `simple_a2a` provides a complete A2A client and server in a single package, built on the async Ruby ecosystem with [Falcon](https://github.com/socketry/falcon) as the recommended HTTP server.
5
+ <div markdown>
6
+
7
+ ![simple_a2a](assets/images/simple_a2a.jpg){ width="100%" }
8
+
9
+ <p align="center"><em>"Anyone speak robot?"</em></p>
10
+
11
+ </div>
12
+
13
+ <div markdown>
14
+
15
+ **A Ruby gem implementing the [Agent2Agent (A2A) protocol](https://a2a-protocol.org/latest/)**
16
+
17
+ A complete A2A client and server in a single package, built on the async Ruby ecosystem with Falcon as the recommended HTTP server.
18
+
19
+ - Full A2A v1.0 protocol, backward compatible with v0.3
20
+ - JSON-RPC 2.0 over HTTP(S)
21
+ - Server-Sent Events (SSE) streaming
22
+ - Push notifications via webhooks (RS256 JWT)
23
+ - Task lifecycle: `submitted → working → completed/failed/canceled`
24
+ - AgentCard discovery at `GET /agentCard`
25
+ - Multi-agent hosting via `A2A.multi_server`
26
+ - `async` gem ecosystem — non-blocking I/O
27
+ - Falcon HTTP server; any Rack-compatible server
28
+ - In-memory storage; pluggable via `Storage::Base`
29
+ - Roda routing, Zeitwerk autoloading
30
+ - [:material-book-open: Full documentation](https://madbomber.github.io/simple_a2a)
31
+
32
+ </div>
33
+
34
+ </div>
35
+
36
+ <p align="center" markdown>
37
+ [:material-book-open: Documentation](https://madbomber.github.io/simple_a2a){ .md-button .md-button--primary }
38
+ [:material-github: GitHub](https://github.com/MadBomber/simple_a2a){ .md-button }
39
+ </p>
6
40
 
7
41
  ---
8
42
 
9
- ## What is A2A?
43
+ ## MCP vs. A2A
10
44
 
11
- The Agent2Agent (A2A) protocol defines how AI agents running on different platforms, frameworks, and vendors can discover each other, exchange tasks, and stream results — without vendor lock-in.
45
+ Two open protocols address different dimensions of AI agent integration and they are designed to complement each other.
12
46
 
13
- - Agents expose a JSON-RPC 2.0 over HTTP endpoint
14
- - Clients send tasks and receive structured results
15
- - Streaming uses Server-Sent Events (SSE)
16
- - Push notifications use webhooks (RS256 JWT)
17
- - AgentCards describe capabilities and skills
47
+ ### MCP Vertical Integration (Agent Environment)
18
48
 
19
- **Protocol Reference:** [https://a2a-protocol.org/latest/](https://a2a-protocol.org/latest/)
49
+ The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), introduced by Anthropic in November 2024, defines how an AI agent connects to the tools, data sources, and services in its environment — file systems, databases, APIs, browsers, and code execution engines. MCP uses a client-server model where the agent is the client and each external capability is a server. This is *vertical* integration: the agent reaches downward into its local context and outward into external services through a uniform interface.
50
+
51
+ ### A2A — Horizontal Integration (Agent ↔ Agent)
52
+
53
+ The [Agent2Agent Protocol](https://a2a-protocol.org/latest/) (A2A), introduced by Google in April 2025 and donated to the Linux Foundation for vendor-neutral governance, defines how autonomous agents running on different platforms, frameworks, and vendors can discover one another, delegate tasks, and stream results in real time. This is *horizontal* integration: peer agents — each with its own specialization, runtime, and vendor — collaborate as equals across organizational and technology boundaries.
54
+
55
+ ### Together
56
+
57
+ MCP and A2A are complementary. A single agent can use MCP to access its tools and A2A to delegate subtasks to peer agents. `simple_a2a` implements the A2A layer.
58
+
59
+ ### References
60
+
61
+ - Anthropic: [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) (November 2024)
62
+ - Google Developers Blog: [A2A: A new era of agent interoperability](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) (April 2025)
63
+ - Linux Foundation: [Launch of the Agent2Agent Protocol Project](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents)
64
+ - A2A Project on GitHub: [https://github.com/a2aproject/A2A](https://github.com/a2aproject/A2A)
20
65
 
21
66
  ---
22
67
 
@@ -61,23 +106,6 @@ puts task.status.state # => "completed"
61
106
 
62
107
  ---
63
108
 
64
- ## Features
65
-
66
- | Feature | Details |
67
- |---|---|
68
- | Protocol | A2A v1.0, backward compatible with v0.3 |
69
- | Transport | JSON-RPC 2.0 over HTTP(S) |
70
- | Streaming | Server-Sent Events (SSE) |
71
- | Push notifications | Webhooks with RS256 JWT signing |
72
- | Task lifecycle | `submitted → working → completed/failed/canceled` |
73
- | Discovery | AgentCard endpoint at `GET /agentCard` |
74
- | Async runtime | `async` gem ecosystem — non-blocking I/O |
75
- | HTTP server | Falcon (recommended), any Rack-compatible server |
76
- | HTTP client | `async-http` (`Async::HTTP::Internet`) |
77
- | Storage | In-memory (thread-safe); pluggable via `Storage::Base` |
78
- | Routing | Roda with JSON-RPC dispatch |
79
- | Autoloading | Zeitwerk |
80
-
81
109
  ## Runnable demos
82
110
 
83
111
  The repository includes three demo applications under `examples/`:
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/04_resubscribe/client.rb
5
+ #
6
+ # Start the server first:
7
+ # bundle exec ruby examples/04_resubscribe/server.rb
8
+ #
9
+ # What this demo shows:
10
+ # 1. Subscriber 1 starts a streaming task via tasks/sendSubscribe.
11
+ # 2. Once the task ID is known, Subscriber 2 attaches via tasks/resubscribe.
12
+ # 3. Subscriber 2's first event is the current Task snapshot — not an
13
+ # artifact or status update — proving it joined an in-flight stream.
14
+ # 4. Both subscribers then receive the remaining events independently.
15
+ # 5. The task completes once; both streams close cleanly.
16
+
17
+ require_relative "../common_config"
18
+ require "async"
19
+
20
+ URL = "http://localhost:9292"
21
+ WIDTH = 42 # column width for side-by-side output
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Helpers
25
+ # ---------------------------------------------------------------------------
26
+ def col(label, text, width = WIDTH)
27
+ prefix = "[#{label}]"
28
+ "#{prefix.ljust(14)} #{text.to_s.ljust(width)}"
29
+ end
30
+
31
+ def divider = puts("─" * (WIDTH * 2 + 16))
32
+
33
+ def print_header
34
+ puts
35
+ puts col("subscriber-1", "tasks/sendSubscribe (new task)") +
36
+ col("subscriber-2", "tasks/resubscribe (join mid-stream)")
37
+ divider
38
+ end
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Agent card — confirm streaming capability
42
+ # ---------------------------------------------------------------------------
43
+ base = A2A.client(url: URL)
44
+ card = base.agent_card
45
+
46
+ puts "=== Agent Card ==="
47
+ puts " Name: #{card.name}"
48
+ puts " Description: #{card.description}"
49
+ puts " Streaming: #{card.capabilities&.streaming}"
50
+ puts
51
+
52
+ abort "Agent does not advertise streaming support." unless card.capabilities&.streaming
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Run both subscribers concurrently inside a single Async reactor.
56
+ # ---------------------------------------------------------------------------
57
+ print_header
58
+
59
+ sub1_events = []
60
+ sub2_events = []
61
+ captured_task_id = nil
62
+
63
+ Async do |reactor|
64
+ client1 = A2A.sse_client(url: URL)
65
+ client2 = A2A.sse_client(url: URL)
66
+
67
+ # Subscriber 1 — starts the task and streams from the beginning.
68
+ sub1_task = reactor.async do
69
+ client1.send_subscribe(message: A2A::Models::Message.user("analyze")) do |event|
70
+ sub1_events << event
71
+
72
+ case event
73
+ when A2A::Models::TaskStatusUpdateEvent
74
+ captured_task_id ||= event.task_id
75
+ label = event.final? ? "status (final): #{event.status.state}" : "status: #{event.status.state}"
76
+ puts col("subscriber-1", label)
77
+
78
+ when A2A::Models::TaskArtifactUpdateEvent
79
+ puts col("subscriber-1", event.artifact.parts.map(&:text).join)
80
+
81
+ when Hash
82
+ puts col("subscriber-1", "(error) #{event.dig('error', 'message')}")
83
+ end
84
+ end
85
+ end
86
+
87
+ # Wait until Subscriber 1 has seen the first event and given us the task ID.
88
+ loop do
89
+ break if captured_task_id
90
+ reactor.sleep(0.05)
91
+ end
92
+
93
+ puts col("subscriber-2", "(resubscribing to task #{captured_task_id[0, 8]}…)")
94
+
95
+ # Subscriber 2 — joins the running task.
96
+ # First event is the Task snapshot; remaining events are the live stream.
97
+ client2.resubscribe(task_id: captured_task_id) do |event|
98
+ sub2_events << event
99
+
100
+ case event
101
+ when Hash
102
+ # Task snapshot — the current state at the moment we subscribed.
103
+ state = event.dig("status", "state") || "unknown"
104
+ puts col("subscriber-2", "(snapshot) state=#{state}, steps so far: #{event['artifacts']&.length || 0}")
105
+
106
+ when A2A::Models::TaskStatusUpdateEvent
107
+ label = event.final? ? "status (final): #{event.status.state}" : "status: #{event.status.state}"
108
+ puts col("subscriber-2", label)
109
+
110
+ when A2A::Models::TaskArtifactUpdateEvent
111
+ puts col("subscriber-2", event.artifact.parts.map(&:text).join)
112
+ end
113
+ end
114
+
115
+ sub1_task.wait
116
+ end
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Summary
120
+ # ---------------------------------------------------------------------------
121
+ divider
122
+ puts
123
+ puts "=== Summary ==="
124
+
125
+ snapshot = sub2_events.find { |e| e.is_a?(Hash) && e.key?("status") }
126
+ sub2_artifacts = sub2_events.select { |e| e.is_a?(A2A::Models::TaskArtifactUpdateEvent) }
127
+ sub1_artifacts = sub1_events.select { |e| e.is_a?(A2A::Models::TaskArtifactUpdateEvent) }
128
+
129
+ puts " Subscriber 1 — events received : #{sub1_events.length}"
130
+ puts " Subscriber 1 — artifact steps : #{sub1_artifacts.length}"
131
+ puts
132
+ puts " Subscriber 2 — events received : #{sub2_events.length}"
133
+ puts " Subscriber 2 — task snapshot : #{snapshot ? 'yes' : 'no (unexpected)'}"
134
+ puts " Subscriber 2 — artifact steps : #{sub2_artifacts.length}"
135
+ puts " Subscriber 2 — joined at step : #{sub1_artifacts.length - sub2_artifacts.length + 1} of 5"
136
+ puts
137
+ puts " Both streams terminated cleanly: #{
138
+ sub1_events.any? { |e| e.is_a?(A2A::Models::TaskStatusUpdateEvent) && e.final? } &&
139
+ sub2_events.any? { |e| e.is_a?(A2A::Models::TaskStatusUpdateEvent) && e.final? }
140
+ }"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/04_resubscribe/server.rb
5
+ #
6
+ # Demonstrates tasks/resubscribe — multiple concurrent SSE subscribers
7
+ # watching the same running task.
8
+
9
+ require_relative "../common_config"
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Executor — simulates a multi-step analysis pipeline with visible pauses
13
+ # so a second subscriber has time to attach mid-stream.
14
+ # ---------------------------------------------------------------------------
15
+ class AnalysisExecutor < A2A::Server::AgentExecutor
16
+ STEPS = [
17
+ "Collecting data from sources",
18
+ "Filtering and normalising records",
19
+ "Running statistical analysis",
20
+ "Detecting anomalies",
21
+ "Generating final report"
22
+ ].freeze
23
+
24
+ STEP_DELAY = 1.2 # seconds between steps — wide enough for client to resubscribe
25
+
26
+ def call(ctx)
27
+ ctx.task.start!
28
+ ctx.emit_status
29
+
30
+ STEPS.each_with_index do |description, i|
31
+ sleep STEP_DELAY
32
+
33
+ artifact = A2A::Models::Artifact.new(
34
+ index: i,
35
+ parts: [A2A::Models::Part.text("Step #{i + 1}/#{STEPS.length}: #{description}")],
36
+ last_chunk: true
37
+ )
38
+ ctx.emit_artifact(artifact, last_chunk: true)
39
+ end
40
+
41
+ ctx.task.complete!
42
+ ctx.emit_status(final: true)
43
+ end
44
+ end
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Agent card
48
+ # ---------------------------------------------------------------------------
49
+ card = A2A::Models::AgentCard.new(
50
+ name: "AnalysisAgent",
51
+ version: "1.0",
52
+ description: "Multi-step analysis pipeline — demonstrates tasks/resubscribe",
53
+ capabilities: A2A::Models::AgentCapabilities.new(streaming: true),
54
+ skills: [
55
+ A2A::Models::AgentSkill.new(
56
+ name: "analyze",
57
+ description: "Runs a five-step analysis and streams each step as it completes"
58
+ )
59
+ ],
60
+ interfaces: [
61
+ A2A::Models::AgentInterface.new(
62
+ type: "json-rpc",
63
+ url: "http://localhost:9292",
64
+ version: "1.0"
65
+ )
66
+ ]
67
+ )
68
+
69
+ total = AnalysisExecutor::STEPS.length * AnalysisExecutor::STEP_DELAY
70
+ puts "Starting AnalysisAgent on http://localhost:9292"
71
+ puts "Task runs #{AnalysisExecutor::STEPS.length} steps × #{AnalysisExecutor::STEP_DELAY}s = ~#{total.round(0).to_i}s per run"
72
+ puts "Press Ctrl-C to stop."
73
+ puts
74
+
75
+ A2A.server(agent_card: card, executor: AnalysisExecutor.new).run
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/05_cancellation/client.rb
5
+ #
6
+ # Start the server first:
7
+ # bundle exec ruby examples/05_cancellation/server.rb
8
+ #
9
+ # What this demo shows:
10
+ # 1. Three tasks (A, B, C) are started via tasks/sendSubscribe so each
11
+ # runs asynchronously inside Falcon's reactor.
12
+ # 2. After 3 seconds — while all tasks are still mid-flight — task B is
13
+ # cancelled via tasks/cancel.
14
+ # 3. Task B's SSE stream receives a final canceled status event and closes.
15
+ # 4. Tasks A and C run to completion, unaffected.
16
+ # 5. Final states: A=completed, B=canceled, C=completed.
17
+
18
+ require_relative "../common_config"
19
+
20
+ URL = "http://localhost:9292"
21
+ CANCEL_SEC = 3 # seconds before we cancel task B
22
+
23
+ def divider = puts("─" * 60)
24
+
25
+ def col(label, text)
26
+ "[task #{label}] #{text}"
27
+ end
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Confirm the server is up and supports streaming
31
+ # ---------------------------------------------------------------------------
32
+ card = A2A.client(url: URL).agent_card
33
+
34
+ puts
35
+ puts "=== Agent Card ==="
36
+ puts " Name: #{card.name}"
37
+ puts " Description: #{card.description}"
38
+ puts " Streaming: #{card.capabilities&.streaming}"
39
+ puts
40
+ abort "Agent does not advertise streaming support." unless card.capabilities&.streaming
41
+ divider
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Start three streaming tasks in parallel threads.
45
+ # Each thread drives its own SSE loop and records every event it receives.
46
+ # A shared, mutex-protected hash lets the main thread see task IDs as soon
47
+ # as the first status event arrives from each task.
48
+ # ---------------------------------------------------------------------------
49
+ puts
50
+ puts "Starting tasks A, B, and C via tasks/sendSubscribe…"
51
+ puts "(each would take 10 s; task B will be cancelled after #{CANCEL_SEC}s)"
52
+ puts
53
+
54
+ task_ids = {}
55
+ id_mutex = Mutex.new
56
+ all_events = { "A" => [], "B" => [], "C" => [] }
57
+
58
+ threads = %w[A B C].map do |label|
59
+ Thread.new do
60
+ A2A.sse_client(url: URL).send_subscribe(
61
+ message: A2A::Models::Message.user("task #{label}")
62
+ ) do |event|
63
+ case event
64
+ when A2A::Models::TaskStatusUpdateEvent
65
+ id_mutex.synchronize { task_ids[label] ||= event.task_id }
66
+ all_events[label] << event
67
+ state = event.status.state
68
+ msg = event.status.message ? " (#{event.status.message})" : ""
69
+ puts col(label, "status=#{state}#{msg}")
70
+
71
+ when A2A::Models::TaskArtifactUpdateEvent
72
+ all_events[label] << event
73
+
74
+ when Hash
75
+ puts col(label, "error: #{event.dig('error', 'message')}")
76
+ end
77
+ end
78
+ rescue => e
79
+ puts col(label, "stream error: #{e.message}")
80
+ end
81
+ end
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Wait until task B has checked in (first status event), then cancel it.
85
+ # ---------------------------------------------------------------------------
86
+ loop do
87
+ sleep 0.1
88
+ break if id_mutex.synchronize { task_ids["B"] }
89
+ end
90
+
91
+ puts
92
+ puts "Task B is running — waiting #{CANCEL_SEC}s then cancelling…"
93
+ sleep CANCEL_SEC
94
+
95
+ task_b_id = id_mutex.synchronize { task_ids["B"] }
96
+ puts
97
+ begin
98
+ cancelled = A2A.client(url: URL).cancel_task(task_b_id)
99
+ puts col("B", "cancel sent → state=#{cancelled.status.state}")
100
+ rescue A2A::Error => e
101
+ puts col("B", "cancel failed: #{e.message}")
102
+ end
103
+ puts
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Wait for all three SSE streams to close.
107
+ # ---------------------------------------------------------------------------
108
+ threads.each(&:join)
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Final summary
112
+ # ---------------------------------------------------------------------------
113
+ divider
114
+ puts
115
+ puts "=== Final States ==="
116
+
117
+ %w[A B C].each do |label|
118
+ events = all_events[label]
119
+ last_st = events.select { |e| e.is_a?(A2A::Models::TaskStatusUpdateEvent) }.last
120
+ artifact = events.select { |e| e.is_a?(A2A::Models::TaskArtifactUpdateEvent) }
121
+ .last&.artifact&.parts&.first&.text
122
+
123
+ state = last_st&.status&.state || "unknown"
124
+ line = "state=#{state.ljust(10)} events received=#{events.length}"
125
+ line += " result: #{artifact}" if artifact
126
+ line += " ← cancelled mid-flight" if state == "canceled"
127
+ puts col(label, line)
128
+ end
129
+
130
+ puts
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Assertions
134
+ # ---------------------------------------------------------------------------
135
+ puts "=== Verification ==="
136
+
137
+ def final_state(events)
138
+ events.select { |e| e.is_a?(A2A::Models::TaskStatusUpdateEvent) }
139
+ .last&.status&.state
140
+ end
141
+
142
+ a_ok = final_state(all_events["A"]) == "completed"
143
+ b_ok = final_state(all_events["B"]) == "canceled"
144
+ c_ok = final_state(all_events["C"]) == "completed"
145
+
146
+ puts " Task A completed normally : #{a_ok ? 'PASS' : 'FAIL'}"
147
+ puts " Task B was cancelled : #{b_ok ? 'PASS' : 'FAIL'}"
148
+ puts " Task C completed normally : #{c_ok ? 'PASS' : 'FAIL'}"
149
+ puts
150
+ puts(a_ok && b_ok && c_ok ? "All assertions passed." : "One or more assertions failed.")
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: bundle exec ruby examples/05_cancellation/server.rb
5
+ #
6
+ # Hosts a single agent whose tasks take ~10 seconds to complete,
7
+ # giving the client time to cancel individual tasks while others
8
+ # keep running.
9
+
10
+ require_relative "../common_config"
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Executor — simulates a slow 10-step pipeline (1 s per step).
14
+ # Emits a status event after each step so the SSE stream shows progress.
15
+ # Checks ctx.task.terminal? between steps so a cancel takes effect promptly.
16
+ # ---------------------------------------------------------------------------
17
+ class SlowExecutor < A2A::Server::AgentExecutor
18
+ STEPS = 10
19
+ STEP_SEC = 1.0
20
+
21
+ def call(ctx)
22
+ ctx.task.start!
23
+ ctx.emit_status
24
+
25
+ STEPS.times do |i|
26
+ return if ctx.task.terminal?
27
+ sleep STEP_SEC
28
+ return if ctx.task.terminal?
29
+
30
+ ctx.task.status = A2A::Models::TaskStatus.new(
31
+ state: A2A::Models::Types::TaskState::WORKING,
32
+ message: "Step #{i + 1}/#{STEPS} complete"
33
+ )
34
+ ctx.emit_status
35
+ end
36
+
37
+ return if ctx.task.terminal?
38
+
39
+ ctx.task.complete!(artifacts: [
40
+ A2A::Models::Artifact.new(
41
+ name: "result",
42
+ parts: [A2A::Models::Part.text("All #{STEPS} steps finished for task #{ctx.task.id[0, 8]}")]
43
+ )
44
+ ])
45
+ ctx.emit_status(final: true)
46
+ end
47
+ end
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Agent card
51
+ # ---------------------------------------------------------------------------
52
+ card = A2A::Models::AgentCard.new(
53
+ name: "SlowAgent",
54
+ version: "1.0",
55
+ description: "A slow 10-step agent — demonstrates mid-flight task cancellation",
56
+ capabilities: A2A::Models::AgentCapabilities.new(streaming: true),
57
+ skills: [
58
+ A2A::Models::AgentSkill.new(
59
+ name: "slow_task",
60
+ description: "Runs 10 steps at 1 s each; can be cancelled at any point"
61
+ )
62
+ ],
63
+ interfaces: [
64
+ A2A::Models::AgentInterface.new(
65
+ type: "json-rpc",
66
+ url: "http://localhost:9292",
67
+ version: "1.0"
68
+ )
69
+ ]
70
+ )
71
+
72
+ puts "Starting SlowAgent on http://localhost:9292"
73
+ puts "Each task takes #{SlowExecutor::STEPS * SlowExecutor::STEP_SEC}s to complete without cancellation."
74
+ puts "Press Ctrl-C to stop."
75
+ puts
76
+
77
+ A2A.server(agent_card: card, executor: SlowExecutor.new).run