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,460 @@
1
+ # Compare & Contrast: `agent2agent` vs `simple_a2a`
2
+
3
+ **`agent2agent`** — https://github.com/general-intelligence-systems/agent2agent
4
+ **`simple_a2a`** — https://github.com/MadBomber/simple_a2a
5
+ **Reviewed:** 2026-05-08
6
+
7
+ ---
8
+
9
+ ## 1. Philosophy and Scope
10
+
11
+ | | `agent2agent` | `simple_a2a` |
12
+ |---|---|---|
13
+ | **Philosophy** | Spec-first: derive everything from the canonical `.proto` and `.json` schema files | Pragmatic: hand-craft a clean, minimal Ruby interface that covers real use cases |
14
+ | **Complexity** | Heavy — brings in protobuf parsing, JSON Schema validation, SQLite, structured logging | Lightweight — each component is a short, readable Ruby class |
15
+ | **Target user** | Teams that need the full A2A spec surface, including multi-tenant and OpenTelemetry | Individual developers or small teams who want a drop-in, understandable library |
16
+ | **License** | Apache 2.0 | MIT |
17
+ | **Ruby requirement** | >= 3.2 | >= 3.2 |
18
+ | **Namespace** | `A2A` | `A2A` (same) |
19
+ | **Gem name** | `agent2agent` | `simple_a2a` |
20
+ | **Authorship** | "A2A Contributors" (organizational) | Dewayne VanHoozer (individual) |
21
+
22
+ ---
23
+
24
+ ## 2. Protocol Coverage
25
+
26
+ ### Operations Implemented
27
+
28
+ | Operation | `agent2agent` | `simple_a2a` |
29
+ |---|---|---|
30
+ | SendMessage / `tasks/send` | Yes | Yes |
31
+ | SendStreamingMessage / `tasks/sendSubscribe` | Yes | Yes |
32
+ | GetTask / `tasks/get` | Yes | Yes |
33
+ | ListTasks / `tasks/list` | Yes | Yes |
34
+ | CancelTask / `tasks/cancel` | Yes | Yes |
35
+ | SubscribeToTask (SSE poll of existing task) | Yes | No |
36
+ | CreateTaskPushNotificationConfig | Yes (full) | Stub only |
37
+ | GetTaskPushNotificationConfig | Yes (full) | Stub only |
38
+ | ListTaskPushNotificationConfigs | Yes (full) | Stub only |
39
+ | DeleteTaskPushNotificationConfig | Yes (full) | Stub only |
40
+ | GetExtendedAgentCard | Yes | No |
41
+
42
+ `agent2agent` implements all 11 A2A protocol operations. `simple_a2a` implements 5 core operations plus push notification stubs.
43
+
44
+ ### Transport Bindings
45
+
46
+ | | `agent2agent` | `simple_a2a` |
47
+ |---|---|---|
48
+ | **JSON-RPC 2.0** | Yes — `POST /a2a` | Yes — `POST /` |
49
+ | **HTTP+JSON REST** | Yes — `POST /message:send`, `GET /tasks/{id}`, etc. | No |
50
+ | **Agent Discovery** | `GET /.well-known/agent-card.json` (spec-compliant path) | `GET /agentCard` (non-standard path) |
51
+
52
+ `agent2agent` supports both the JSON-RPC and REST wire formats from the spec. `simple_a2a` is JSON-RPC only. The agent card path difference means `simple_a2a` is not spec-compliant for discovery.
53
+
54
+ ### Protocol Versioning
55
+
56
+ `simple_a2a` checks the `A2A-Version` request header and rejects unsupported versions (`SUPPORTED_VERSIONS = %w[1.0 0.3]`) with a JSON-RPC error response. `agent2agent` does not implement version negotiation.
57
+
58
+ ### Multi-Tenant Paths
59
+
60
+ `agent2agent` supports tenant-prefixed route variants (`/{tenant}/message:send`, etc.) built into its REST binding. `simple_a2a` has no multi-tenant concept, but its `MultiAgent` class achieves path-based isolation via `Rack::URLMap`.
61
+
62
+ ---
63
+
64
+ ## 3. Server Architecture
65
+
66
+ ### `agent2agent` — Rack Middleware Chain
67
+
68
+ ```
69
+ Rack::URLMap
70
+ /.well-known/agent-card.json → AgentCardHandler
71
+ /a2a → Bindings::JsonRpc → Server::Env → Server::Triage → Server::Dispatcher
72
+ / → Bindings::Rest → Server::Env → Server::Triage → Server::Dispatcher
73
+ ```
74
+
75
+ - Each operation has its own handler class in `lib/a2a/server/` (11 handler files)
76
+ - `Triage` resolves the target operation from either the JSON-RPC method name or the REST verb+path — derived from `data/a2a.proto` at load time, not hard-coded
77
+ - `Dispatcher` routes `env["a2a.operation"]` to registered handler objects via duck-typing
78
+
79
+ ### `simple_a2a` — Roda App
80
+
81
+ ```
82
+ A2A::Server::App < Roda
83
+ GET /agentCard → return agent_card.to_h
84
+ POST / → parse JSON-RPC, dispatch by method name (case statement)
85
+ tasks/sendSubscribe → handle_send_subscribe (SSE)
86
+ tasks/send → handle_send
87
+ tasks/get → handle_get
88
+ tasks/list → handle_list
89
+ tasks/cancel → handle_cancel
90
+ tasks/pushNotification/* → handle_push_* (stubs)
91
+ ```
92
+
93
+ - Uses Roda plugins: `json`, `json_parser`, `halt`, `all_verbs`
94
+ - Dispatch is a `case rpc_req.method` in `App#dispatch` — explicit and readable but requires a code change for each new operation
95
+ - `Server::Base` is the public entry point; it creates a fresh anonymous `App` subclass per instance so class-level state doesn't bleed between servers
96
+
97
+ **Key difference:** `agent2agent` is extensible via proto-driven operation registration; `simple_a2a` is explicit but static. Adding a new operation in `simple_a2a` means editing `dispatch`; in `agent2agent`, it follows automatically from the proto file.
98
+
99
+ ---
100
+
101
+ ## 4. Agent / Executor Model
102
+
103
+ ### `agent2agent` — DSL with `on` blocks
104
+
105
+ ```ruby
106
+ agent = A2A::Agent.new do
107
+ on "SendMessage", "SendStreamingMessage" do |context|
108
+ task = context.store.create(context.request)
109
+ stream = context.stream
110
+ Async do
111
+ result = robot.run(context.request.params[:message])
112
+ context.store.complete(task.id, result)
113
+ stream.event(result, type: "result")
114
+ stream.finish
115
+ end
116
+ context.respond(task)
117
+ end
118
+ end
119
+ server = A2A::Server.new
120
+ server.register(agent)
121
+ ```
122
+
123
+ The `Agent` DSL maps operation names to blocks. Multiple operations can share one handler. The `Context` object exposes `respond(result)`, `stream`, `store`, `agent_card`, and `request`. The `respond` call returns a synchronous response; SSE is opt-in via `context.stream`.
124
+
125
+ ### `simple_a2a` — Abstract class with `#call` / `#cancel`
126
+
127
+ ```ruby
128
+ class MyExecutor < A2A::Server::AgentExecutor
129
+ def call(context)
130
+ context.task.working!
131
+ context.emit_status
132
+ result = do_work(context.message)
133
+ context.task.complete!(result)
134
+ context.emit_status(final: true)
135
+ end
136
+ end
137
+
138
+ server = A2A::Server::Base.new(
139
+ agent_card: card,
140
+ executor: MyExecutor.new,
141
+ storage: A2A::Storage::Memory.new
142
+ )
143
+ server.run
144
+ ```
145
+
146
+ `AgentExecutor` is an abstract class with `#call(context)` and a default `#cancel(context)` that calls `task.cancel!`. The executor is responsible for advancing task state explicitly. The `Context` object exposes `task`, `message`, `storage`, `event_router`, `save_task`, `emit_status`, and `emit_artifact`.
147
+
148
+ **Key difference:** `agent2agent`'s DSL is more concise for simple cases but less obvious for control flow. `simple_a2a`'s abstract class pattern is more explicit about what the implementer must do and is easier to test in isolation.
149
+
150
+ ---
151
+
152
+ ## 5. Context Object
153
+
154
+ | Method / Attribute | `agent2agent` | `simple_a2a` |
155
+ |---|---|---|
156
+ | Access the task | via `context.store` | `context.task` (direct reference) |
157
+ | Send a response | `context.respond(result)` | return value from `#call` (App handles it) |
158
+ | SSE stream | `context.stream` | SSE handled transparently by App; executor yields events via `emit_status` / `emit_artifact` |
159
+ | Task store | `context.store` | `context.storage` |
160
+ | Agent card | `context.agent_card` | not on context — on `App` class |
161
+ | Inbound message | `context.request` | `context.message` |
162
+ | Persist task | via `store.complete(...)` | `context.save_task` or `context.emit_status` (auto-saves) |
163
+ | Emit status event | N/A (store methods do it) | `context.emit_status(final: false)` |
164
+ | Emit artifact event | N/A | `context.emit_artifact(artifact, append:, last_chunk:)` |
165
+
166
+ `simple_a2a`'s context makes task-state advancement and event emission first-class operations. `agent2agent`'s context is thinner on the executor side — more work goes through the store.
167
+
168
+ ---
169
+
170
+ ## 6. Schema and Models
171
+
172
+ ### `agent2agent` — Spec-Driven Dynamic Classes
173
+
174
+ - Loads `data/a2a.json` (47-type JSON Schema bundle, 73 KB) at startup
175
+ - Dynamically generates one `Definition` subclass per schema type
176
+ - Each class provides: snake_case readers, `to_h` (camelCase output), `valid?`/`valid!` (full JSON Schema validation via `json_schemer`), and `==`
177
+ - Also parses `data/a2a.proto` (34 KB) to derive operation metadata, HTTP bindings, and streaming flags
178
+ - **Validation:** full JSON Schema compliance against the official A2A spec
179
+
180
+ ### `simple_a2a` — Hand-Written `Models::Base` DSL
181
+
182
+ ```ruby
183
+ class Message < Base
184
+ attribute :role, type: String, required: true
185
+ attribute :parts, type: [Part]
186
+ attribute :metadata
187
+ end
188
+ ```
189
+
190
+ - ~15 hand-written model classes in `lib/simple_a2a/models/`
191
+ - `Models::Base` provides: `attribute` macro, `from_hash` (with camelCase/snake_case coercion), `to_h`, `to_json`, `valid?` (required-field check only), `==`
192
+ - Type coercion handles nested models and arrays of models
193
+ - **Validation:** only checks `required:` fields; no JSON Schema enforcement
194
+
195
+ **Key difference:** `agent2agent` stays in sync with the official spec automatically (update the JSON/proto files, get new model classes). `simple_a2a` requires manual updates when the spec changes but is far easier to read and debug. `simple_a2a`'s validation is deliberately minimal — it trusts that callers pass valid data.
196
+
197
+ ---
198
+
199
+ ## 7. SSE Streaming
200
+
201
+ Both use `Protocol::HTTP::Body::Writable` as the SSE body — the right approach for Falcon compatibility.
202
+
203
+ ### `agent2agent`
204
+
205
+ - `A2A::SSE::Stream < Protocol::HTTP::Body::Writable`
206
+ - Two subclasses: `JsonRpcStream` (wraps events in JSON-RPC envelope) and `RestStream` (plain event format)
207
+ - `stream.event(hash, type: "result")` writes formatted SSE chunks
208
+ - `stream.finish` calls `close_write`
209
+ - The executor has direct access to the stream via `context.stream`
210
+
211
+ ### `simple_a2a`
212
+
213
+ - Uses `Protocol::HTTP::Body::Writable` directly, not subclassed
214
+ - In `handle_send_subscribe`, an anonymous object (duck-typed router) is created inline that writes SSE-formatted JSON-RPC events to the writable body
215
+ - The executor runs in a sibling `Async::Task`; it calls `emit_status` / `emit_artifact` which publish to the anonymous router, which writes to the writable body
216
+ - `output.close_write` is called in the `ensure` block
217
+
218
+ **Key difference:** `agent2agent`'s stream is a first-class object the executor interacts with directly. `simple_a2a`'s SSE wiring is internal to `App` — the executor emits domain events and the App translates them to SSE. The `simple_a2a` approach is cleaner for the executor author but the anonymous-object pattern in `handle_send_subscribe` is the trickiest code in the library.
219
+
220
+ ---
221
+
222
+ ## 8. Task Storage
223
+
224
+ ### `agent2agent`
225
+
226
+ Two implementations:
227
+ - `A2A::TaskStore` — in-memory, mutex-synchronized
228
+ - `A2A::Sqlite` (~16 KB) — WAL mode, indexed, production-ready, fiber-safe via `Async::Queue` pub/sub
229
+
230
+ Both include integrated pub/sub (`Store::PubSub`) and webhook delivery (`Store::Webhooks`). Pub/sub uses `Async::Queue` per task subscriber — fiber-safe without locks.
231
+
232
+ ### `simple_a2a`
233
+
234
+ One implementation:
235
+ - `Storage::Memory` — in-memory, mutex-synchronized (`@mutex = Mutex.new`)
236
+ - `Storage::Base` — abstract base class defines the interface: `save`, `find`, `find!`, `delete`, `list`, `size`, `clear`
237
+
238
+ No persistent storage. Pub/sub is separated from storage into `EventRouter`.
239
+
240
+ **Key difference:** `agent2agent` ships production storage out of the box. `simple_a2a` provides the extension interface but the implementer must write their own persistent store. `agent2agent`'s storage and pub/sub are tightly coupled; `simple_a2a` separates them.
241
+
242
+ ---
243
+
244
+ ## 9. Pub/Sub and Event Routing
245
+
246
+ ### `agent2agent` — `Store::PubSub` with `Async::Queue`
247
+
248
+ - Each task gets an `Async::Queue` per subscriber
249
+ - `nil` sentinel signals end-of-stream
250
+ - Fully fiber-safe — no locks needed under Falcon's fiber scheduler
251
+ - Integrated into the task store; the `SubscribeToTask` operation connects clients to these queues
252
+
253
+ ### `simple_a2a` — `EventRouter` wrapping `TypedBus`
254
+
255
+ ```ruby
256
+ @bus = TypedBus::MessageBus.new
257
+ @bus.add_channel(task_id.to_sym, type: nil, timeout: nil)
258
+ @bus.publish(sym, event)
259
+ @bus.subscribe(sym) { |delivery| block.call(delivery.message); delivery.ack! }
260
+ ```
261
+
262
+ - Channels are keyed by task ID (as Symbol)
263
+ - Typed, ack-based message delivery via `typed_bus` gem
264
+ - `EventRouter` is dependency-injected — in SSE mode, `App` swaps in an anonymous router that writes directly to the `Writable` body
265
+
266
+ **Key difference:** `agent2agent`'s `Async::Queue` is fiber-native and simpler internally. `simple_a2a`'s `TypedBus` adds ack semantics and typing but introduces an external dependency. The swap-in anonymous router for SSE is a clever workaround for the fact that `TypedBus` isn't directly wired to the HTTP body.
267
+
268
+ ---
269
+
270
+ ## 10. Client
271
+
272
+ ### `agent2agent` — Dynamically Generated Methods
273
+
274
+ - Methods generated from `Proto.operations` mapped to snake_case
275
+ - Uses `Async::HTTP::Internet` for non-blocking HTTP
276
+ - JSON-RPC 2.0 requests to `/a2a`
277
+ - Auto-incrementing request IDs
278
+ - Also fetches `/.well-known/agent-card.json`
279
+
280
+ ### `simple_a2a` — Hand-Written Named Methods
281
+
282
+ ```ruby
283
+ client.agent_card
284
+ client.send_task(message: msg)
285
+ client.get_task(task_id)
286
+ client.list_tasks
287
+ client.cancel_task(task_id)
288
+ ```
289
+
290
+ - `Async::HTTP::Internet` — same concurrency model
291
+ - Context-aware: works inside an existing `Async::Task` or creates its own
292
+ - Methods return typed `Models::*` objects
293
+ - Also includes `Client::SSE` for consuming streaming responses
294
+
295
+ **Key difference:** `agent2agent`'s dynamic generation means new operations are available automatically when the proto file is updated. `simple_a2a`'s hand-written methods are easier to introspect, document, and test, but require manual additions for new operations. `simple_a2a`'s async context awareness (using existing task if present, creating one if not) is a practical improvement.
296
+
297
+ ---
298
+
299
+ ## 11. Push Notifications
300
+
301
+ ### `agent2agent`
302
+
303
+ - Full async webhook delivery via `Store::Webhooks` + `Async::HTTP`
304
+ - Per-task push notification config CRUD with full protocol storage
305
+ - Webhook auth: `Authorization: Bearer <credentials>` + optional `X-A2A-Notification-Token`
306
+ - Runs delivery in background fibers
307
+
308
+ ### `simple_a2a`
309
+
310
+ - `PushSender` class with real HTTP delivery via `Net::HTTP`
311
+ - JWT signing (RS256) with payload hash, configurable private key + key ID + issuer
312
+ - Supports `bearer` (JWT), `token` (static), or custom header auth schemes
313
+ - 5/10 second open/read timeout
314
+ - Push notification config CRUD operations are stubs — they respond successfully but do not store configs
315
+
316
+ **Key difference:** `agent2agent` has full push notification infrastructure. `simple_a2a` has a capable delivery mechanism (`PushSender`) but the server-side config storage is not implemented — the stubs accept requests without persisting anything. A real push notification flow requires the implementer to wire `PushSender` into the executor manually.
317
+
318
+ ---
319
+
320
+ ## 12. Multi-Agent Support
321
+
322
+ ### `agent2agent`
323
+
324
+ ```ruby
325
+ server = A2A::Server.new
326
+ server.register(agent1)
327
+ server.register(agent2)
328
+ ```
329
+
330
+ Multiple agents registered on a single server; the dispatcher routes to the correct agent based on its declared operations.
331
+
332
+ ### `simple_a2a`
333
+
334
+ ```ruby
335
+ A2A::Server::MultiAgent.new(
336
+ agents: {
337
+ "/anthropic" => { agent_card: card1, executor: exec1 },
338
+ "/openai" => { agent_card: card2, executor: exec2 }
339
+ },
340
+ port: 9292
341
+ ).run
342
+ ```
343
+
344
+ `MultiAgent` uses `Rack::URLMap` — each agent gets its own URL path prefix. Each agent gets a fresh anonymous `App` subclass so class-level config state doesn't bleed.
345
+
346
+ **Key difference:** `agent2agent` shares a single endpoint; agents are distinguished by operation. `simple_a2a` isolates agents at the URL level — different agents at different paths. The `Rack::URLMap` approach is architecturally cleaner for true multi-agent isolation.
347
+
348
+ ---
349
+
350
+ ## 13. Dependencies
351
+
352
+ ### Runtime comparison
353
+
354
+ | Gem | `agent2agent` | `simple_a2a` |
355
+ |---|---|---|
356
+ | `async` | Yes | Yes |
357
+ | `async-http` | Yes | Yes |
358
+ | `rack` | Yes | Yes |
359
+ | `protocol-http` | Yes (explicit) | Yes (via falcon) |
360
+ | `falcon` | Dev only | Yes (runtime) |
361
+ | `roda` | No | Yes |
362
+ | `google-protobuf` | Yes | No |
363
+ | `json_schemer` | Yes | No |
364
+ | `sqlite3` | Yes | No |
365
+ | `console` | Yes | No |
366
+ | `scampi` | Yes (inline tests) | No |
367
+ | `jwt` | No | Yes |
368
+ | `simple_flow` | No | Yes |
369
+ | `typed_bus` | No | Yes |
370
+ | `zeitwerk` | No | Yes |
371
+ | `logger` | No | Yes |
372
+
373
+ `agent2agent` pulls in protobuf, JSON Schema validation, and SQLite. `simple_a2a` pulls in Roda, JWT, and two smaller gems (`typed_bus`, `simple_flow`). `agent2agent` is heavier in binary dependencies (native extensions: `google-protobuf`, `sqlite3`). `simple_a2a`'s heavier non-obvious dependency is `typed_bus`, which is a less-established gem.
374
+
375
+ ---
376
+
377
+ ## 14. Observability and Tracing
378
+
379
+ | | `agent2agent` | `simple_a2a` |
380
+ |---|---|---|
381
+ | **Structured logging** | `console` gem throughout | `A2A.logger` (stdlib Logger); warn-level in PushSender |
382
+ | **Distributed tracing** | `lib/traces/provider/a2a/` — OpenTelemetry-compatible spans for bindings and dispatcher | None |
383
+ | **Operation counters** | `Store::Processor` tracks call/complete/failed counts | None |
384
+
385
+ `agent2agent` is observability-ready out of the box for teams running distributed agent systems. `simple_a2a` has minimal logging.
386
+
387
+ ---
388
+
389
+ ## 15. Testing Strategy
390
+
391
+ ### `agent2agent` — Inline Tests via `scampi`
392
+
393
+ - Tests live inside source files, co-located with the code they test
394
+ - `test do ... end` blocks with `.should`-style assertions
395
+ - No separate `test/` directory
396
+
397
+ ### `simple_a2a` — Minitest in `test/`
398
+
399
+ - Separate `test/` directory with conventional Minitest structure
400
+ - 98% code coverage measured via `simplecov`
401
+ - `rack-test` for HTTP-level integration tests
402
+ - Separate files per module: `test/server/test_app.rb`, `test/server/test_app_sse.rb`, `test/integration/test_round_trip.rb`, etc.
403
+
404
+ **Key difference:** `scampi` inline tests reduce context-switching but non-standard tooling makes CI integration harder. Minitest is familiar, with rich ecosystem support. `simple_a2a`'s 98% coverage is a strong signal of test completeness.
405
+
406
+ ---
407
+
408
+ ## 16. Examples
409
+
410
+ | | `agent2agent` | `simple_a2a` |
411
+ |---|---|---|
412
+ | Count | 6 | 3 |
413
+ | Basic usage | Yes | Yes (`01_basic_usage/`) |
414
+ | Streaming | Yes | Yes (`02_streaming/`) |
415
+ | LLM integration | No | Yes (`03_llm_research/`) |
416
+ | Async/background jobs | Yes | No |
417
+ | Multi-turn conversation | Yes | No |
418
+ | Multi-agent | Yes | No (but `MultiAgent` class exists) |
419
+ | Push notifications | Yes | No |
420
+ | Docker/Compose | Yes | No |
421
+
422
+ ---
423
+
424
+ ## 17. Summary Assessment
425
+
426
+ ### Where `agent2agent` wins
427
+
428
+ 1. **Spec completeness** — all 11 operations, both transport bindings, spec-compliant agent card path
429
+ 2. **Production storage** — SQLite store ships out of the box; no extra code to write
430
+ 3. **Spec fidelity** — schema and routing auto-update from the canonical proto/JSON files
431
+ 4. **Observability** — OpenTelemetry instrumentation built in
432
+ 5. **Multi-turn support** — `STATE_INPUT_REQUIRED` state machine entry is formally modeled
433
+ 6. **Push notification infrastructure** — full config CRUD + async webhook delivery
434
+ 7. **Example breadth** — async jobs, multi-turn, multi-agent, push notifications all demonstrated
435
+
436
+ ### Where `simple_a2a` wins
437
+
438
+ 1. **Readability** — every class is short, explicit, and readable without spec knowledge
439
+ 2. **Testability** — abstract `AgentExecutor` is easy to test in isolation; 98% coverage
440
+ 3. **Protocol versioning** — `A2A-Version` header negotiation not present in `agent2agent`
441
+ 4. **Multi-agent isolation** — path-based isolation via `Rack::URLMap` is architecturally cleaner
442
+ 5. **Client ergonomics** — async context awareness; returns typed model objects
443
+ 6. **JWT push auth** — RS256 JWT signing for webhook delivery is more sophisticated
444
+ 7. **Lighter binary footprint** — no native protobuf or SQLite extensions required for the base gem
445
+ 8. **Conventional test structure** — Minitest + simplecov is standard, CI-friendly
446
+
447
+ ### The fundamental trade-off
448
+
449
+ `agent2agent` trades complexity for spec completeness and auto-synchronization with the A2A specification. It's the right choice for teams that need the full protocol surface and are running under Falcon with Async throughout.
450
+
451
+ `simple_a2a` trades spec completeness for simplicity and explicitness. It's the right choice when you want to understand every line of your A2A dependency, need conventional testing tools, or are embedding an A2A server into an existing Ruby application without committing to a full fiber-based async stack at every layer.
452
+
453
+ ### Gaps in `simple_a2a` worth closing
454
+
455
+ 1. **Agent card path** — `/agentCard` should be `/.well-known/agent-card.json` for spec compliance
456
+ 2. **REST transport binding** — currently JSON-RPC only; REST endpoints would enable broader interop
457
+ 3. **`SubscribeToTask`** — SSE stream on an existing task is missing; only `sendSubscribe` (new task + stream) is supported
458
+ 4. **`GetExtendedAgentCard`** — not implemented
459
+ 5. **Push notification config persistence** — `PushSender` exists but config CRUD stubs don't store anything
460
+ 6. **Persistent storage** — `Storage::Base` interface is ready; a SQLite or file-backed implementation would make `simple_a2a` production-ready without adding native extensions
@@ -111,6 +111,25 @@ Events are instances of:
111
111
 
112
112
  The stream ends when the server sends a `final: true` event. The block is not called for malformed or comment-only SSE frames.
113
113
 
114
+ ### `#resubscribe(task_id:, &block)`
115
+
116
+ Attaches an SSE stream to an already-running task. The first event yielded to the block is the current Task snapshot (a plain `Hash` — no `type` field); subsequent events are the live stream.
117
+
118
+ ```ruby
119
+ client.resubscribe(task_id: "existing-task-id") do |event|
120
+ case event
121
+ when Hash
122
+ puts "reconnected — state: #{event['status']['state']}"
123
+ when A2A::Models::TaskStatusUpdateEvent
124
+ puts "Status: #{event.status.state} (final=#{event.final})"
125
+ when A2A::Models::TaskArtifactUpdateEvent
126
+ puts "Artifact: #{event.artifact.parts.map(&:text).join}"
127
+ end
128
+ end
129
+ ```
130
+
131
+ Returns `UnsupportedOperationError` (via `A2A::Error`) if the task is terminal or not currently streaming.
132
+
114
133
  ### Using inside an Async reactor
115
134
 
116
135
  Both `Base` and `SSE` detect whether they're already inside an `Async` reactor via `Async::Task.current?`. Inside a reactor, they call the underlying `Async::HTTP::Internet` directly. Outside, they wrap the call in `Async { }.wait`.
data/docs/api/index.md CHANGED
@@ -15,9 +15,10 @@
15
15
  ```ruby
16
16
  A2A.logger = Logger.new($stdout) # optional — logs internal warnings
17
17
 
18
- A2A.server(**opts) # → A2A::Server::Base.new(**opts)
19
- A2A.client(**opts) # → A2A::Client::Base.new(**opts)
20
- A2A.sse_client(**opts) # → A2A::Client::SSE.new(**opts)
18
+ A2A.server(**opts) # → A2A::Server::Base.new(**opts)
19
+ A2A.multi_server(**opts) # → A2A::Server::MultiAgent.new(**opts)
20
+ A2A.client(**opts) # → A2A::Client::Base.new(**opts)
21
+ A2A.sse_client(**opts) # → A2A::Client::SSE.new(**opts)
21
22
  ```
22
23
 
23
24
  ## Constants
@@ -68,13 +68,14 @@ A2A::Models::Artifact.new(
68
68
 
69
69
  | Attribute | Type | Description |
70
70
  |---|---|---|
71
+ | `artifact_id` | String | UUID, auto-generated |
71
72
  | `name` | String | Artifact identifier |
72
73
  | `description` | String | Human-readable description |
73
74
  | `parts` | [Part] | Content parts |
74
- | `index` | Integer | Position in artifact sequence |
75
- | `append` | Boolean | True if this is a streaming chunk appended to a prior artifact |
76
- | `last_chunk` | Boolean | True if this is the final streaming chunk |
77
75
  | `metadata` | Hash | Arbitrary metadata |
76
+ | `extensions` | Array | Protocol extension data |
77
+
78
+ Note: `append` and `last_chunk` are **not** Artifact attributes — they are parameters on `ctx.emit_artifact(artifact, append:, last_chunk:)` and on `TaskArtifactUpdateEvent`.
78
79
 
79
80
  ---
80
81
 
@@ -160,16 +161,16 @@ card = A2A::Models::AgentCard.new(
160
161
  |---|---|---|---|
161
162
  | `streaming` | Boolean | false | Supports `tasks/sendSubscribe` |
162
163
  | `push_notifications` | Boolean | false | Supports webhook push |
163
- | `state_transition_history` | Boolean | false | Preserves task history |
164
+ | `extended_agent_card` | Boolean | false | Exposes extended AgentCard at `GET /agentCard?extended=true` |
164
165
 
165
166
  ### AgentSkill
166
167
 
167
- | Attribute | Description |
168
- |---|---|
169
- | `name` | Skill identifier |
170
- | `description` | Human-readable description |
171
- | `tags` | Searchable tags |
172
- | `examples` | Example prompts |
168
+ | Attribute | Required | Description |
169
+ |---|---|---|
170
+ | `name` | yes | Skill identifier |
171
+ | `description` | | Human-readable description |
172
+ | `input_schema` | | JSON Schema describing accepted input |
173
+ | `output_schema` | | JSON Schema describing produced output |
173
174
 
174
175
  ### AgentInterface
175
176
 
@@ -218,7 +219,8 @@ A2A::Models::TaskArtifactUpdateEvent.new(
218
219
  config = A2A::Models::PushNotificationConfig.new(
219
220
  webhook_url: "https://example.com/hook",
220
221
  authentication_info: A2A::Models::AuthenticationInfo.new(
221
- scheme: "bearer"
222
+ scheme: "bearer",
223
+ value: "" # not used for JWT; PushSender generates the token from the private key
222
224
  )
223
225
  )
224
226
  config.valid? # => true
@@ -52,7 +52,6 @@ Each entry in `agents` accepts the same core configuration used by `Server::Base
52
52
  | `:agent_card` | Yes | AgentCard returned by that path's `/agentCard` endpoint |
53
53
  | `:executor` | Yes | Executor that handles requests for that path |
54
54
  | `:storage` | No | Storage backend for that path; defaults to `A2A::Storage::Memory.new` |
55
- | `:event_router` | No | SSE event router for that path; defaults to a new router |
56
55
  | `:push_sender` | No | Push notification sender for that path |
57
56
 
58
57
  For a runnable example, see the [Multi-Agent LLM Research demo](../../examples/llm-research.md).
@@ -93,7 +92,7 @@ Passed to `AgentExecutor#call`. Provides access to the request and helper method
93
92
  ctx.task # => A2A::Models::Task
94
93
  ctx.message # => A2A::Models::Message (the incoming message)
95
94
  ctx.storage # => A2A::Storage::Base
96
- ctx.event_router # => A2A::Server::EventRouter
95
+ ctx.event_router # => A2A::Server::TaskBroadcast (duck-typed — responds to #publish)
97
96
  ctx.config # => Hash (arbitrary per-request config, default {})
98
97
 
99
98
  ctx.save_task # persists task to storage
@@ -114,17 +113,50 @@ ctx.resume_message # => A2A::Models::Message (the new input from the user)
114
113
 
115
114
  ---
116
115
 
117
- ## Server::EventRouter
116
+ ## Server::TaskBroadcast
118
117
 
119
- Manages per-task SSE channels using `TypedBus`. You rarely interact with this directly — use `ctx.emit_status` and `ctx.emit_artifact` instead.
118
+ Per-task lock-free SSE fan-out. One `TaskBroadcast` is created per streaming task and held in the `BroadcastRegistry` for the duration of that task. You rarely interact with this directly — use `ctx.emit_status` and `ctx.emit_artifact` instead.
120
119
 
121
120
  ```ruby
122
- router = A2A::Server::EventRouter.new
123
- router.open(task_id) # creates a channel
124
- router.publish(task_id, event) # sends an event to subscribers
125
- router.subscribe(task_id) { |event| } # block receives raw event objects
126
- router.close(task_id) # removes the channel
127
- router.channel?(task_id) # => true/false
121
+ broadcast = A2A::Server::TaskBroadcast.new
122
+
123
+ queue = broadcast.subscribe # returns a RactorQueue for this subscriber
124
+ broadcast.publish(task_id, event) # fans event out to all subscriber queues
125
+ broadcast.error("something failed") # fans a BroadcastError sentinel to all queues
126
+ broadcast.close # fans the DONE sentinel — signals end of stream
127
+ broadcast.unsubscribe(queue) # removes one subscriber
128
+ ```
129
+
130
+ Each subscriber gets its own `RactorQueue`. `async_push` / `async_pop` cooperate with the Falcon fiber scheduler via `sleep(0)`.
131
+
132
+ ---
133
+
134
+ ## Server::BroadcastRegistry
135
+
136
+ Thread-safe `task_id → TaskBroadcast` map, held at the App class level and shared across all concurrent requests.
137
+
138
+ ```ruby
139
+ registry = A2A::Server::BroadcastRegistry.new
140
+ registry.register(task_id, broadcast) # called when a streaming task starts
141
+ registry.find(task_id) # => TaskBroadcast or nil
142
+ registry.unregister(task_id) # called when the executor finishes
143
+ ```
144
+
145
+ `tasks/resubscribe` and `tasks/cancel` use `registry.find` to locate the live broadcast for a running task.
146
+
147
+ ---
148
+
149
+ ## Server::PushConfigStore
150
+
151
+ In-memory store for push notification configurations, keyed by task ID. One instance is created per `Server::App` and exposed as `App.push_config_store`. You rarely interact with this directly — the four `tasks/pushNotification/*` handlers use it automatically.
152
+
153
+ ```ruby
154
+ store = A2A::Server::PushConfigStore.new
155
+
156
+ store.set(task_id, config) # => config — stores or replaces the config for this task
157
+ store.get(task_id) # => PushNotificationConfig or nil
158
+ store.delete(task_id) # => the deleted config or nil
159
+ store.list # => { task_id => config, … } snapshot
128
160
  ```
129
161
 
130
162
  ---
@@ -8,7 +8,6 @@ Abstract interface. Subclass this to implement custom storage backends.
8
8
  class MyStorage < A2A::Storage::Base
9
9
  def save(task) = … # persist, return task
10
10
  def find(id) = … # return task or nil
11
- def find!(id) = … # return task or raise A2A::TaskNotFoundError
12
11
  def delete(id) = … # remove task
13
12
  def list = … # return array of all tasks
14
13
  end