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
data/docs/index.md
CHANGED
|
@@ -1,22 +1,67 @@
|
|
|
1
1
|
# simple_a2a
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div class="grid" markdown>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<div markdown>
|
|
6
|
+
|
|
7
|
+
{ 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
|
-
##
|
|
43
|
+
## MCP vs. A2A
|
|
10
44
|
|
|
11
|
-
|
|
45
|
+
Two open protocols address different dimensions of AI agent integration — and they are designed to complement each other.
|
|
12
46
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|