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/architecture/index.md
CHANGED
|
@@ -6,17 +6,18 @@
|
|
|
6
6
|
A2A
|
|
7
7
|
├── Models — data classes (Task, Message, Part, Artifact, AgentCard, …)
|
|
8
8
|
├── Server — HTTP server, routing, executor base, event fan-out
|
|
9
|
-
│ ├── App
|
|
10
|
-
│ ├── Base
|
|
11
|
-
│ ├── AgentExecutor
|
|
12
|
-
│ ├── Context
|
|
13
|
-
│ ├── ResumeContext
|
|
14
|
-
│ ├──
|
|
15
|
-
│ ├──
|
|
16
|
-
│
|
|
9
|
+
│ ├── App (Roda JSON-RPC router)
|
|
10
|
+
│ ├── Base (server bootstrap + Falcon runner)
|
|
11
|
+
│ ├── AgentExecutor (base class for your agent logic)
|
|
12
|
+
│ ├── Context (per-request helper passed to executor)
|
|
13
|
+
│ ├── ResumeContext (Context + resume_message for interrupted tasks)
|
|
14
|
+
│ ├── TaskBroadcast (RactorQueue-based SSE fan-out per running task)
|
|
15
|
+
│ ├── BroadcastRegistry (task_id → TaskBroadcast map, shared across requests)
|
|
16
|
+
│ ├── PushSender (webhook delivery with JWT signing)
|
|
17
|
+
│ └── FalconRunner (Falcon adapter)
|
|
17
18
|
├── Client — async HTTP client
|
|
18
19
|
│ ├── Base (JSON-RPC client over async/http)
|
|
19
|
-
│ └── SSE (streaming subscribe client)
|
|
20
|
+
│ └── SSE (streaming subscribe + resubscribe client)
|
|
20
21
|
├── Storage — task persistence
|
|
21
22
|
│ ├── Base (abstract interface)
|
|
22
23
|
│ └── Memory (in-process, thread-safe)
|
|
@@ -33,21 +34,22 @@ App (Roda)
|
|
|
33
34
|
│ JSON-RPC parse + dispatch
|
|
34
35
|
│
|
|
35
36
|
▼
|
|
36
|
-
handle_send
|
|
37
|
+
handle_send / handle_send_subscribe
|
|
37
38
|
│ creates Task (submitted)
|
|
38
|
-
│ builds Context
|
|
39
|
+
│ builds Context (with TaskBroadcast as event_router)
|
|
39
40
|
│
|
|
40
41
|
▼
|
|
41
42
|
Executor#call(ctx) ← your code lives here
|
|
42
43
|
│ ctx.task.start!
|
|
43
|
-
│ ctx.emit_artifact(…) →
|
|
44
|
+
│ ctx.emit_artifact(…) → TaskBroadcast → RactorQueue(s) → SSE subscriber(s)
|
|
44
45
|
│ ctx.task.complete!(…)
|
|
45
46
|
│
|
|
46
47
|
▼
|
|
47
48
|
Storage#save(task)
|
|
48
49
|
│
|
|
49
50
|
▼
|
|
50
|
-
JsonRpc::Response (task hash) → HTTP response
|
|
51
|
+
JsonRpc::Response (task hash) → HTTP response [tasks/send]
|
|
52
|
+
SSE stream closes → HTTP body ends [tasks/sendSubscribe / tasks/resubscribe]
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
## Key design decisions
|
|
@@ -58,6 +60,6 @@ JsonRpc::Response (task hash) → HTTP response
|
|
|
58
60
|
|
|
59
61
|
**One executor instance** — `Server::Base` holds a single executor object. For concurrent requests, executors must be stateless (or use per-call state inside `#call`).
|
|
60
62
|
|
|
61
|
-
**Pluggable storage** — `Storage::Base` defines the interface (`save`, `find
|
|
63
|
+
**Pluggable storage** — `Storage::Base` defines the interface (`save`, `find`, `list`, `delete`). `find!` (raises on missing) is a convenience method on `Storage::Memory` only — not part of the required interface. Swap in Redis or PostgreSQL by subclassing and passing your implementation to `Server::Base`.
|
|
62
64
|
|
|
63
|
-
**
|
|
65
|
+
**TaskBroadcast + BroadcastRegistry** — each streaming task gets one `TaskBroadcast`, which holds one `RactorQueue` per SSE subscriber. The broadcast is registered in `BroadcastRegistry` for the duration of the task so that `tasks/resubscribe` and `tasks/cancel` can locate it by task ID. `RactorQueue#async_push` / `#async_pop` cooperate with the Falcon fiber scheduler via `sleep(0)`, keeping the event loop non-blocking. Multiple concurrent subscribers (original `sendSubscribe` and any number of `resubscribe` clients) each receive every event independently.
|
|
@@ -42,6 +42,7 @@ All A2A communication uses **JSON-RPC 2.0 over HTTP POST** to a single endpoint
|
|
|
42
42
|
|---|---|
|
|
43
43
|
| `tasks/send` | Send a message, execute synchronously, return completed task |
|
|
44
44
|
| `tasks/sendSubscribe` | Send a message, stream events via SSE |
|
|
45
|
+
| `tasks/resubscribe` | Attach an SSE stream to an existing running task |
|
|
45
46
|
| `tasks/get` | Retrieve a task by ID |
|
|
46
47
|
| `tasks/list` | List all tasks |
|
|
47
48
|
| `tasks/cancel` | Cancel a non-terminal task |
|
|
@@ -99,7 +100,9 @@ Interrupted tasks can be resumed by sending a new message.
|
|
|
99
100
|
|
|
100
101
|
## Streaming (SSE)
|
|
101
102
|
|
|
102
|
-
`tasks/sendSubscribe`
|
|
103
|
+
Both `tasks/sendSubscribe` and `tasks/resubscribe` keep the HTTP connection open and stream `text/event-stream` events.
|
|
104
|
+
|
|
105
|
+
**`tasks/sendSubscribe`** — creates a new task and immediately begins streaming:
|
|
103
106
|
|
|
104
107
|
```
|
|
105
108
|
data: {"jsonrpc":"2.0","result":{"type":"TaskStatusUpdateEvent","taskId":"…","status":{"state":"working"},"final":false}}
|
|
@@ -109,4 +112,16 @@ data: {"jsonrpc":"2.0","result":{"type":"TaskArtifactUpdateEvent","taskId":"…"
|
|
|
109
112
|
data: {"jsonrpc":"2.0","result":{"type":"TaskStatusUpdateEvent","taskId":"…","status":{"state":"completed"},"final":true}}
|
|
110
113
|
```
|
|
111
114
|
|
|
115
|
+
**`tasks/resubscribe`** — attaches to an existing running task. The first event is always the current Task snapshot (no `type` field); subsequent events are the live stream:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
data: {"jsonrpc":"2.0","result":{"id":"…","contextId":"…","status":{"state":"working",…},"artifacts":[]}}
|
|
119
|
+
|
|
120
|
+
data: {"jsonrpc":"2.0","result":{"type":"TaskArtifactUpdateEvent","taskId":"…","artifact":{…},"final":false}}
|
|
121
|
+
|
|
122
|
+
data: {"jsonrpc":"2.0","result":{"type":"TaskStatusUpdateEvent","taskId":"…","status":{"state":"completed"},"final":true}}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`tasks/resubscribe` returns `UnsupportedOperationError` if the task is already in a terminal state or is not currently streaming. Multiple clients may subscribe to the same task concurrently.
|
|
126
|
+
|
|
112
127
|
Events with `"final": true` signal that the stream is complete.
|
|
Binary file
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# 07 Agent Chaining
|
|
2
|
+
|
|
3
|
+
**Run it:**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bundle exec ruby examples/run 07_agent_chaining
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**What it shows:** one agent calling peer agents using `A2A.client` inside its executor — the same client interface an external caller would use.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
| File | Purpose |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `examples/07_agent_chaining/server.rb` | Three agents at `/reverse`, `/shout`, `/pipeline`; `PipelineExecutor` chains the other two |
|
|
18
|
+
| `examples/07_agent_chaining/client.rb` | External client that speaks only to `/pipeline`; the internal delegation is invisible to it |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## The scenario
|
|
23
|
+
|
|
24
|
+
The demo mounts three agents on one server:
|
|
25
|
+
|
|
26
|
+
| Path | Agent | What it does |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `/reverse` | `ReverseAgent` | Returns the input string with characters reversed |
|
|
29
|
+
| `/shout` | `ShoutAgent` | Returns the input string uppercased with `!!!` appended |
|
|
30
|
+
| `/pipeline` | `PipelineAgent` | Calls `/reverse` then `/shout` internally and returns the final result |
|
|
31
|
+
|
|
32
|
+
The external client sends one message to `/pipeline`. The pipeline executor calls the other two agents in sequence using `A2A.client` and returns their combined output. The external client never learns that two additional A2A calls were made internally.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Server — `PipelineExecutor`
|
|
37
|
+
|
|
38
|
+
The executor holds pre-built `A2A.client` instances pointing to its peer agents:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class PipelineExecutor < A2A::Server::AgentExecutor
|
|
42
|
+
def initialize(reverse_url:, shout_url:)
|
|
43
|
+
@reverse_client = A2A.client(url: reverse_url)
|
|
44
|
+
@shout_client = A2A.client(url: shout_url)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call(ctx)
|
|
48
|
+
input = ctx.message.text_content.strip
|
|
49
|
+
|
|
50
|
+
reversed = @reverse_client
|
|
51
|
+
.send_task(message: A2A::Models::Message.user(input))
|
|
52
|
+
.artifacts.first&.parts&.first&.text
|
|
53
|
+
|
|
54
|
+
shouted = @shout_client
|
|
55
|
+
.send_task(message: A2A::Models::Message.user(reversed))
|
|
56
|
+
.artifacts.first&.parts&.first&.text
|
|
57
|
+
|
|
58
|
+
ctx.task.complete!(artifacts: [
|
|
59
|
+
A2A::Models::Artifact.new(
|
|
60
|
+
name: "result",
|
|
61
|
+
parts: [A2A::Models::Part.text(shouted)]
|
|
62
|
+
)
|
|
63
|
+
])
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
All three agents are mounted on one port via `A2A.multi_server`:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
A2A.multi_server(
|
|
72
|
+
agents: {
|
|
73
|
+
"/reverse" => { agent_card: reverse_card, executor: ReverseExecutor.new },
|
|
74
|
+
"/shout" => { agent_card: shout_card, executor: ShoutExecutor.new },
|
|
75
|
+
"/pipeline" => { agent_card: pipeline_card, executor: pipeline_executor }
|
|
76
|
+
},
|
|
77
|
+
port: 9292
|
|
78
|
+
).run
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Client flow
|
|
84
|
+
|
|
85
|
+
The external client discovers all three agent cards then calls the pipeline:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
client = A2A.client(url: "http://localhost:9292/pipeline")
|
|
89
|
+
task = client.send_task(message: A2A::Models::Message.user("hello world"))
|
|
90
|
+
puts task.artifacts.first.parts.first.text
|
|
91
|
+
# => "DLROW OLLEH!!!"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The client also fetches agent cards from all three paths to show they are independently discoverable.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Protocol coverage
|
|
99
|
+
|
|
100
|
+
| Spec section | What the demo shows |
|
|
101
|
+
|---|---|
|
|
102
|
+
| Agent-to-agent delegation | An executor uses `A2A.client` to call peer agents during task execution |
|
|
103
|
+
| `A2A.multi_server` | Three agents hosted at `/reverse`, `/shout`, `/pipeline` on one port |
|
|
104
|
+
| Agent Card discovery | Client discovers all three cards; pipeline card describes its composed capability |
|
|
105
|
+
| `tasks/send` | Sub-agents called synchronously within the pipeline executor's fiber |
|
|
106
|
+
| Protocol transparency | Internal A2A calls use the same JSON-RPC wire format as external calls |
|
|
107
|
+
| Composability | Any agent can act as both a server (to its caller) and a client (to its dependencies) |
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# 10 Auth Headers
|
|
2
|
+
|
|
3
|
+
**Run it:**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bundle exec ruby examples/run 10_auth_headers
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**What it shows:** injecting custom HTTP headers (Bearer token) into every client request, and wrapping the server with Rack middleware to enforce them.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
| File | Purpose |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `examples/10_auth_headers/server.rb` | `BearerAuthMiddleware` wraps the standard `rack_app`; agent card stays public |
|
|
18
|
+
| `examples/10_auth_headers/client.rb` | Two clients — one without headers (rejected) and one with (accepted) |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## The scenario
|
|
23
|
+
|
|
24
|
+
The server enforces a Bearer token on all `POST /` (RPC) requests. `GET /agentCard` is deliberately left public so agents remain discoverable without credentials.
|
|
25
|
+
|
|
26
|
+
Two clients are created with the same URL:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
unauth_client = A2A.client(url: URL)
|
|
30
|
+
auth_client = A2A.client(url: URL, headers: { "Authorization" => "Bearer #{TOKEN}" })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `unauth_client.agent_card` → succeeds (GET is public)
|
|
34
|
+
- `unauth_client.send_task(...)` → raises `A2A::Error` ("Unauthorized")
|
|
35
|
+
- `auth_client.send_task(...)` → succeeds
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Server — Rack middleware composition
|
|
40
|
+
|
|
41
|
+
The middleware pattern keeps the library unchanged:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class BearerAuthMiddleware
|
|
45
|
+
def initialize(app, token:)
|
|
46
|
+
@app = app
|
|
47
|
+
@token = token
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(env)
|
|
51
|
+
if env["REQUEST_METHOD"] == "POST"
|
|
52
|
+
auth = env["HTTP_AUTHORIZATION"].to_s
|
|
53
|
+
unless auth == "Bearer #{@token}"
|
|
54
|
+
body = JSON.generate({
|
|
55
|
+
"jsonrpc" => "2.0", "id" => nil,
|
|
56
|
+
"error" => { "code" => -32_000, "message" => "Unauthorized: valid Bearer token required" }
|
|
57
|
+
})
|
|
58
|
+
return [200, { "Content-Type" => "application/json" }, [body]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
@app.call(env)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
inner_app = A2A.server(agent_card: card, executor: SecureEchoExecutor.new).rack_app
|
|
66
|
+
auth_app = BearerAuthMiddleware.new(inner_app, token: VALID_TOKEN)
|
|
67
|
+
A2A::Server::FalconRunner.new(auth_app, port: 9292).run
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The middleware returns a JSON-RPC shaped error body (not a bare HTTP 401) so the `A2A::Error` rescue path on the client works without special-casing.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## The `headers:` option
|
|
75
|
+
|
|
76
|
+
`A2A.client(headers:)` and `A2A.sse_client(headers:)` accept any `Hash` of header name → value pairs, which are merged into every request:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# Bearer token
|
|
80
|
+
A2A.client(url: URL, headers: { "Authorization" => "Bearer secret" })
|
|
81
|
+
|
|
82
|
+
# API key
|
|
83
|
+
A2A.client(url: URL, headers: { "X-Api-Key" => "key123" })
|
|
84
|
+
|
|
85
|
+
# Multiple headers
|
|
86
|
+
A2A.client(url: URL, headers: {
|
|
87
|
+
"Authorization" => "Bearer token",
|
|
88
|
+
"X-Tenant-Id" => "acme"
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The same option applies to `A2A.sse_client` for streaming subscriptions.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Protocol coverage
|
|
97
|
+
|
|
98
|
+
| Spec section | What the demo shows |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `A2A.client(headers:)` | `headers: { "Authorization" => "Bearer token" }` appended to every request |
|
|
101
|
+
| `AgentCard` public discovery | `GET /agentCard` succeeds without authentication — agents are discoverable |
|
|
102
|
+
| Bearer token authentication | `Authorization: Bearer <token>` header checked on all POST (RPC) requests |
|
|
103
|
+
| Rack middleware composition | `BearerAuthMiddleware.new(rack_app, token:)` wraps the standard app without library changes |
|
|
104
|
+
| JSON-RPC error on rejection | Middleware returns a well-formed JSON-RPC error body so the client can rescue `A2A::Error` |
|
|
105
|
+
| Header flexibility | The same `headers:` option supports API keys, custom schemes, and any HTTP header |
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# 05 Cancellation
|
|
2
|
+
|
|
3
|
+
**Run it:**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bundle exec ruby examples/run 05_cancellation
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**What it shows:** how to cancel an in-flight task mid-execution while other concurrent tasks run to completion.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
| File | Purpose |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `examples/05_cancellation/server.rb` | `SlowExecutor` that runs 10 one-second steps and checks `ctx.task.terminal?` between each step |
|
|
18
|
+
| `examples/05_cancellation/client.rb` | Starts three concurrent SSE subscriptions, cancels the middle task after 3 s, verifies final states |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## The scenario
|
|
23
|
+
|
|
24
|
+
Three tasks (A, B, C) start simultaneously via `tasks/sendSubscribe`. Each runs a 10-step loop with 1-second pauses — a 10-second total runtime without intervention. After 3 seconds the client calls `tasks/cancel` on Task B. Task B transitions to `canceled`; Tasks A and C run to completion unaffected.
|
|
25
|
+
|
|
26
|
+
This demonstrates three protocol guarantees:
|
|
27
|
+
|
|
28
|
+
1. **Mid-flight cancellation** — `tasks/cancel` interrupts a running task without touching sibling tasks.
|
|
29
|
+
2. **Cooperative cancellation** — the executor checks `ctx.task.terminal?` between steps and exits cleanly when cancelled.
|
|
30
|
+
3. **Terminal state isolation** — the `canceled` state is terminal; subsequent executor steps are skipped.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Server — `SlowExecutor`
|
|
35
|
+
|
|
36
|
+
The executor emits one status event per step and checks for cancellation between steps:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class SlowExecutor < A2A::Server::AgentExecutor
|
|
40
|
+
def call(ctx)
|
|
41
|
+
ctx.task.start!
|
|
42
|
+
ctx.emit_status
|
|
43
|
+
|
|
44
|
+
10.times do |i|
|
|
45
|
+
break if ctx.task.terminal? # exit early if cancelled
|
|
46
|
+
sleep 1
|
|
47
|
+
ctx.emit_artifact(A2A::Models::Artifact.new(
|
|
48
|
+
parts: [A2A::Models::Part.text("step #{i + 1}/10")]
|
|
49
|
+
))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return if ctx.task.terminal?
|
|
53
|
+
ctx.task.complete!
|
|
54
|
+
ctx.emit_status(final: true)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The `AgentCapabilities` declares `streaming: true` so clients know to use `tasks/sendSubscribe`.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Client — concurrent tasks with mid-flight cancel
|
|
64
|
+
|
|
65
|
+
Three SSE subscriptions run in separate threads. Each thread captures its task ID from the first status event:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
task_ids = {}
|
|
69
|
+
mutex = Mutex.new
|
|
70
|
+
|
|
71
|
+
threads = %w[A B C].map do |label|
|
|
72
|
+
Thread.new do
|
|
73
|
+
A2A.sse_client(url: URL).send_subscribe(message: ...) do |event|
|
|
74
|
+
if event.is_a?(A2A::Models::TaskStatusUpdateEvent)
|
|
75
|
+
mutex.synchronize { task_ids[label] ||= event.task_id }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sleep 3 until task_ids.key?("B")
|
|
82
|
+
client.cancel_task(task_ids["B"])
|
|
83
|
+
threads.each(&:join)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
After all threads finish, the client calls `client.get_task` for each ID and asserts the expected states:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
Task A: completed ✓
|
|
90
|
+
Task B: canceled ✓
|
|
91
|
+
Task C: completed ✓
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Protocol coverage
|
|
97
|
+
|
|
98
|
+
| Spec section | What the demo shows |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `tasks/cancel` | Client sends a cancel request by task ID while the task is mid-execution |
|
|
101
|
+
| `canceled` terminal state | Task B transitions to `canceled`; its SSE stream receives a final status event and closes |
|
|
102
|
+
| Concurrent task isolation | Tasks A and C are unaffected by the cancellation of task B |
|
|
103
|
+
| `AgentExecutor#cancel` | Default implementation calls `task.cancel!` and emits a final status event |
|
|
104
|
+
| `TaskState` lifecycle | `submitted → working → canceled` vs `submitted → working → completed` |
|
|
105
|
+
| Cooperative cancellation | Executor checks `ctx.task.terminal?` between steps and exits early when cancelled |
|
data/docs/examples/index.md
CHANGED
|
@@ -1,52 +1,112 @@
|
|
|
1
1
|
# Examples
|
|
2
2
|
|
|
3
|
-
The `examples/` directory contains runnable demo applications that exercise the gem from a client and server process. Each demo uses `examples/common_config.rb`, which adds the repository `lib/` directory to `$LOAD_PATH` before requiring `simple_a2a`, so the examples run against the local checkout.
|
|
3
|
+
The `examples/` directory contains eleven runnable demo applications that exercise the gem from a client and server process. Each demo uses `examples/common_config.rb`, which adds the repository `lib/` directory to `$LOAD_PATH` before requiring `simple_a2a`, so the examples run against the local checkout.
|
|
4
4
|
|
|
5
|
-
<svg viewBox="0 0
|
|
5
|
+
<svg viewBox="0 0 830 510" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="examples-title examples-desc">
|
|
6
6
|
<title id="examples-title">simple_a2a example applications</title>
|
|
7
|
-
<desc id="examples-desc">Dark themed
|
|
7
|
+
<desc id="examples-desc">Dark themed diagram showing all eleven example applications in a 3×4 grid with the A2A capabilities each demonstrates.</desc>
|
|
8
8
|
<defs>
|
|
9
|
-
<linearGradient id="blue"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<linearGradient id="
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<linearGradient id="amber" x1="0" x2="1">
|
|
18
|
-
<stop offset="0" stop-color="#fbbf24"/>
|
|
19
|
-
<stop offset="1" stop-color="#f97316"/>
|
|
20
|
-
</linearGradient>
|
|
21
|
-
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
22
|
-
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#000000" flood-opacity="0.35"/>
|
|
9
|
+
<linearGradient id="eg-blue" x1="0" x2="1"><stop offset="0" stop-color="#38bdf8"/><stop offset="1" stop-color="#2563eb"/></linearGradient>
|
|
10
|
+
<linearGradient id="eg-green" x1="0" x2="1"><stop offset="0" stop-color="#34d399"/><stop offset="1" stop-color="#16a34a"/></linearGradient>
|
|
11
|
+
<linearGradient id="eg-amber" x1="0" x2="1"><stop offset="0" stop-color="#fbbf24"/><stop offset="1" stop-color="#f97316"/></linearGradient>
|
|
12
|
+
<linearGradient id="eg-violet" x1="0" x2="1"><stop offset="0" stop-color="#a78bfa"/><stop offset="1" stop-color="#7c3aed"/></linearGradient>
|
|
13
|
+
<linearGradient id="eg-teal" x1="0" x2="1"><stop offset="0" stop-color="#2dd4bf"/><stop offset="1" stop-color="#0891b2"/></linearGradient>
|
|
14
|
+
<linearGradient id="eg-rose" x1="0" x2="1"><stop offset="0" stop-color="#fb7185"/><stop offset="1" stop-color="#e11d48"/></linearGradient>
|
|
15
|
+
<filter id="eg-glow" x="-10%" y="-10%" width="120%" height="120%">
|
|
16
|
+
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity="0.35"/>
|
|
23
17
|
</filter>
|
|
24
18
|
</defs>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
|
|
20
|
+
<!-- Row 0 -->
|
|
21
|
+
<g filter="url(#eg-glow)">
|
|
22
|
+
<rect x="15" y="15" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-blue)" stroke-width="2"/>
|
|
23
|
+
<rect x="285" y="15" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-green)" stroke-width="2"/>
|
|
24
|
+
<rect x="555" y="15" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-amber)" stroke-width="2"/>
|
|
25
|
+
</g>
|
|
26
|
+
<!-- Row 1 -->
|
|
27
|
+
<g filter="url(#eg-glow)">
|
|
28
|
+
<rect x="15" y="135" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-blue)" stroke-width="2"/>
|
|
29
|
+
<rect x="285" y="135" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-amber)" stroke-width="2"/>
|
|
30
|
+
<rect x="555" y="135" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-green)" stroke-width="2"/>
|
|
28
31
|
</g>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<rect x="
|
|
32
|
-
<rect x="
|
|
32
|
+
<!-- Row 2 -->
|
|
33
|
+
<g filter="url(#eg-glow)">
|
|
34
|
+
<rect x="15" y="255" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-violet)" stroke-width="2"/>
|
|
35
|
+
<rect x="285" y="255" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-amber)" stroke-width="2"/>
|
|
36
|
+
<rect x="555" y="255" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-teal)" stroke-width="2"/>
|
|
33
37
|
</g>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<text x="
|
|
43
|
-
<text x="
|
|
44
|
-
<text x="
|
|
45
|
-
<text x="
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<text x="
|
|
49
|
-
<text x="
|
|
38
|
+
<!-- Row 3 -->
|
|
39
|
+
<g filter="url(#eg-glow)">
|
|
40
|
+
<rect x="15" y="375" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-rose)" stroke-width="2"/>
|
|
41
|
+
<rect x="285" y="375" width="260" height="110" rx="10" fill="#0f172a" stroke="url(#eg-violet)" stroke-width="2"/>
|
|
42
|
+
</g>
|
|
43
|
+
|
|
44
|
+
<g font-family="Inter, ui-sans-serif, system-ui, sans-serif">
|
|
45
|
+
<!-- 01 Basic Usage -->
|
|
46
|
+
<text x="30" y="47" fill="#e2e8f0" font-size="17" font-weight="700">01 Basic Usage</text>
|
|
47
|
+
<text x="30" y="67" fill="#93c5fd" font-size="12">JSON-RPC request/response</text>
|
|
48
|
+
<text x="30" y="85" fill="#cbd5e1" font-size="12">agent card discovery</text>
|
|
49
|
+
<text x="30" y="103" fill="#cbd5e1" font-size="12">tasks/send · get · list · errors</text>
|
|
50
|
+
|
|
51
|
+
<!-- 02 Streaming -->
|
|
52
|
+
<text x="300" y="47" fill="#e2e8f0" font-size="17" font-weight="700">02 Streaming</text>
|
|
53
|
+
<text x="300" y="67" fill="#86efac" font-size="12">tasks/sendSubscribe · SSE</text>
|
|
54
|
+
<text x="300" y="85" fill="#cbd5e1" font-size="12">working / completed status</text>
|
|
55
|
+
<text x="300" y="103" fill="#cbd5e1" font-size="12">incremental artifact chunks</text>
|
|
56
|
+
|
|
57
|
+
<!-- 03 LLM Research -->
|
|
58
|
+
<text x="570" y="47" fill="#e2e8f0" font-size="17" font-weight="700">03 LLM Research</text>
|
|
59
|
+
<text x="570" y="67" fill="#fcd34d" font-size="12">multi-agent · multi_server</text>
|
|
60
|
+
<text x="570" y="85" fill="#cbd5e1" font-size="12">parallel SSE · evaluator</text>
|
|
61
|
+
<text x="570" y="103" fill="#cbd5e1" font-size="12">CLI + Sinatra web client</text>
|
|
62
|
+
|
|
63
|
+
<!-- 04 Resubscribe -->
|
|
64
|
+
<text x="30" y="167" fill="#e2e8f0" font-size="17" font-weight="700">04 Resubscribe</text>
|
|
65
|
+
<text x="30" y="187" fill="#93c5fd" font-size="12">tasks/resubscribe</text>
|
|
66
|
+
<text x="30" y="205" fill="#cbd5e1" font-size="12">mid-stream join · snapshot</text>
|
|
67
|
+
<text x="30" y="223" fill="#cbd5e1" font-size="12">concurrent subscribers</text>
|
|
68
|
+
|
|
69
|
+
<!-- 05 Cancellation -->
|
|
70
|
+
<text x="300" y="167" fill="#e2e8f0" font-size="17" font-weight="700">05 Cancellation</text>
|
|
71
|
+
<text x="300" y="187" fill="#fcd34d" font-size="12">tasks/cancel</text>
|
|
72
|
+
<text x="300" y="205" fill="#cbd5e1" font-size="12">concurrent tasks · lifecycle</text>
|
|
73
|
+
<text x="300" y="223" fill="#cbd5e1" font-size="12">cooperative cancellation</text>
|
|
74
|
+
|
|
75
|
+
<!-- 06 Push Notifications -->
|
|
76
|
+
<text x="570" y="167" fill="#e2e8f0" font-size="17" font-weight="700">06 Push Notifications</text>
|
|
77
|
+
<text x="570" y="187" fill="#86efac" font-size="12">pushNotification/set/get/del</text>
|
|
78
|
+
<text x="570" y="205" fill="#cbd5e1" font-size="12">webhook delivery · PushSender</text>
|
|
79
|
+
<text x="570" y="223" fill="#cbd5e1" font-size="12">out-of-band events</text>
|
|
80
|
+
|
|
81
|
+
<!-- 07 Agent Chaining -->
|
|
82
|
+
<text x="30" y="287" fill="#e2e8f0" font-size="17" font-weight="700">07 Agent Chaining</text>
|
|
83
|
+
<text x="30" y="307" fill="#c4b5fd" font-size="12">A2A.client inside executor</text>
|
|
84
|
+
<text x="30" y="325" fill="#cbd5e1" font-size="12">agent-to-agent delegation</text>
|
|
85
|
+
<text x="30" y="343" fill="#cbd5e1" font-size="12">composable pipelines</text>
|
|
86
|
+
|
|
87
|
+
<!-- 08 Interrupted States -->
|
|
88
|
+
<text x="300" y="287" fill="#e2e8f0" font-size="17" font-weight="700">08 Interrupted States</text>
|
|
89
|
+
<text x="300" y="307" fill="#fcd34d" font-size="12">input_required · auth_required</text>
|
|
90
|
+
<text x="300" y="325" fill="#cbd5e1" font-size="12">multi-turn conversations</text>
|
|
91
|
+
<text x="300" y="343" fill="#cbd5e1" font-size="12">message context_id threading</text>
|
|
92
|
+
|
|
93
|
+
<!-- 09 Multipart -->
|
|
94
|
+
<text x="570" y="287" fill="#e2e8f0" font-size="17" font-weight="700">09 Multipart</text>
|
|
95
|
+
<text x="570" y="307" fill="#5eead4" font-size="12">text · json · binary · url</text>
|
|
96
|
+
<text x="570" y="325" fill="#cbd5e1" font-size="12">Part predicates · base64</text>
|
|
97
|
+
<text x="570" y="343" fill="#cbd5e1" font-size="12">multi-type artifact</text>
|
|
98
|
+
|
|
99
|
+
<!-- 10 Auth Headers -->
|
|
100
|
+
<text x="30" y="407" fill="#e2e8f0" font-size="17" font-weight="700">10 Auth Headers</text>
|
|
101
|
+
<text x="30" y="427" fill="#fda4af" font-size="12">A2A.client(headers:)</text>
|
|
102
|
+
<text x="30" y="445" fill="#cbd5e1" font-size="12">Bearer token middleware</text>
|
|
103
|
+
<text x="30" y="463" fill="#cbd5e1" font-size="12">Rack middleware composition</text>
|
|
104
|
+
|
|
105
|
+
<!-- 11 SQLite Storage -->
|
|
106
|
+
<text x="300" y="407" fill="#e2e8f0" font-size="17" font-weight="700">11 SQLite Storage</text>
|
|
107
|
+
<text x="300" y="427" fill="#c4b5fd" font-size="12">Storage::Base injection</text>
|
|
108
|
+
<text x="300" y="445" fill="#cbd5e1" font-size="12">WAL persistence · Brewfile</text>
|
|
109
|
+
<text x="300" y="463" fill="#cbd5e1" font-size="12">cross-restart task survival</text>
|
|
50
110
|
</g>
|
|
51
111
|
</svg>
|
|
52
112
|
|
|
@@ -56,7 +116,8 @@ From the repository root:
|
|
|
56
116
|
|
|
57
117
|
```bash
|
|
58
118
|
bundle exec ruby examples/run 01_basic_usage
|
|
59
|
-
bundle exec ruby examples/run
|
|
119
|
+
bundle exec ruby examples/run 05_cancellation
|
|
120
|
+
bundle exec ruby examples/run 11_sqlite_storage
|
|
60
121
|
```
|
|
61
122
|
|
|
62
123
|
The launcher starts the demo server on `http://localhost:9292`, waits for it to accept connections, runs the demo client, and then shuts the server down.
|
|
@@ -68,25 +129,35 @@ bundle exec ruby examples/01_basic_usage/server.rb
|
|
|
68
129
|
bundle exec ruby examples/01_basic_usage/client.rb
|
|
69
130
|
```
|
|
70
131
|
|
|
71
|
-
|
|
132
|
+
Some demos (`03_llm_research`, `11_sqlite_storage`) have custom `run` scripts that manage a more complex lifecycle; the top-level launcher detects and delegates to them automatically.
|
|
72
133
|
|
|
73
|
-
|
|
134
|
+
## Demo-specific dependencies
|
|
74
135
|
|
|
75
|
-
|
|
76
|
-
bundle add ruby_llm async-http-faraday sinatra
|
|
77
|
-
```
|
|
136
|
+
Most demos run with the gem's standard development setup (`bundle install`).
|
|
78
137
|
|
|
79
|
-
|
|
138
|
+
**Demo 03 — LLM Research** requires LLM provider API keys and additional gems not in the gem's runtime dependency list:
|
|
80
139
|
|
|
81
140
|
```bash
|
|
141
|
+
bundle add ruby_llm async-http-faraday sinatra
|
|
82
142
|
export ANTHROPIC_API_KEY=your_key_here
|
|
83
143
|
export OPENAI_API_KEY=your_key_here
|
|
144
|
+
bundle exec ruby examples/run 03_llm_research
|
|
84
145
|
```
|
|
85
146
|
|
|
147
|
+
**Demo 11 — SQLite Storage** has its own `Gemfile` and `Brewfile`. The `run` script installs the sqlite3 binary (via Homebrew on macOS) and runs `bundle install` with the local Gemfile before starting the server. No manual setup is required.
|
|
148
|
+
|
|
86
149
|
## Demo index
|
|
87
150
|
|
|
88
|
-
| Demo |
|
|
89
|
-
|
|
90
|
-
| Basic Usage | `
|
|
91
|
-
| Streaming | `
|
|
92
|
-
|
|
|
151
|
+
| # | Demo | Run command | Documentation |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| 01 | Basic Usage | `examples/run 01_basic_usage` | [Basic Usage](basic-usage.md) |
|
|
154
|
+
| 02 | Streaming | `examples/run 02_streaming` | [Streaming](streaming.md) |
|
|
155
|
+
| 03 | LLM Research | `examples/run 03_llm_research` | [LLM Research](llm-research.md) |
|
|
156
|
+
| 04 | Resubscribe | `examples/run 04_resubscribe` | [Resubscribe](resubscribe.md) |
|
|
157
|
+
| 05 | Cancellation | `examples/run 05_cancellation` | [Cancellation](cancellation.md) |
|
|
158
|
+
| 06 | Push Notifications | `examples/run 06_push_notifications` | [Push Notifications](push-notifications.md) |
|
|
159
|
+
| 07 | Agent Chaining | `examples/run 07_agent_chaining` | [Agent Chaining](agent-chaining.md) |
|
|
160
|
+
| 08 | Interrupted States | `examples/run 08_interrupted_states` | [Interrupted States](interrupted-states.md) |
|
|
161
|
+
| 09 | Multipart Artifacts | `examples/run 09_multipart` | [Multipart](multipart.md) |
|
|
162
|
+
| 10 | Auth Headers | `examples/run 10_auth_headers` | [Auth Headers](auth-headers.md) |
|
|
163
|
+
| 11 | SQLite Storage | `examples/run 11_sqlite_storage` | [SQLite Storage](sqlite-storage.md) |
|