simple_a2a 0.1.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 +7 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +192 -0
- data/Rakefile +13 -0
- data/docs/api/client/index.md +124 -0
- data/docs/api/index.md +27 -0
- data/docs/api/models/index.md +233 -0
- data/docs/api/server/index.md +162 -0
- data/docs/api/storage/index.md +84 -0
- data/docs/architecture/index.md +63 -0
- data/docs/architecture/protocol.md +112 -0
- data/docs/assets/css/custom.css +6 -0
- data/docs/examples/basic-usage.md +77 -0
- data/docs/examples/index.md +92 -0
- data/docs/examples/llm-research.md +92 -0
- data/docs/examples/streaming.md +81 -0
- data/docs/getting-started/installation.md +48 -0
- data/docs/getting-started/quick-start.md +100 -0
- data/docs/guides/custom-storage.md +69 -0
- data/docs/guides/push-notifications.md +104 -0
- data/docs/guides/streaming.md +75 -0
- data/docs/index.md +98 -0
- data/examples/01_basic_usage/client.rb +75 -0
- data/examples/01_basic_usage/server.rb +57 -0
- data/examples/02_streaming/client.rb +70 -0
- data/examples/02_streaming/server.rb +177 -0
- data/examples/03_llm_research/client.rb +138 -0
- data/examples/03_llm_research/run +82 -0
- data/examples/03_llm_research/server.rb +203 -0
- data/examples/03_llm_research/web_client.rb +501 -0
- data/examples/common_config.rb +4 -0
- data/examples/run +108 -0
- data/lib/simple_a2a/client/base.rb +101 -0
- data/lib/simple_a2a/client/sse.rb +58 -0
- data/lib/simple_a2a/errors.rb +15 -0
- data/lib/simple_a2a/json_rpc.rb +89 -0
- data/lib/simple_a2a/models/agent_capabilities.rb +11 -0
- data/lib/simple_a2a/models/agent_card.rb +23 -0
- data/lib/simple_a2a/models/agent_interface.rb +11 -0
- data/lib/simple_a2a/models/agent_provider.rb +11 -0
- data/lib/simple_a2a/models/agent_skill.rb +12 -0
- data/lib/simple_a2a/models/artifact.rb +23 -0
- data/lib/simple_a2a/models/authentication_info.rb +11 -0
- data/lib/simple_a2a/models/base.rb +111 -0
- data/lib/simple_a2a/models/message.rb +45 -0
- data/lib/simple_a2a/models/part.rb +45 -0
- data/lib/simple_a2a/models/push_notification_config.rb +17 -0
- data/lib/simple_a2a/models/security_scheme.rb +16 -0
- data/lib/simple_a2a/models/send_message_configuration.rb +12 -0
- data/lib/simple_a2a/models/stream_response.rb +32 -0
- data/lib/simple_a2a/models/task.rb +57 -0
- data/lib/simple_a2a/models/task_artifact_update_event.rb +21 -0
- data/lib/simple_a2a/models/task_status.rb +20 -0
- data/lib/simple_a2a/models/task_status_update_event.rb +19 -0
- data/lib/simple_a2a/models/types.rb +39 -0
- data/lib/simple_a2a/server/agent_executor.rb +16 -0
- data/lib/simple_a2a/server/app.rb +227 -0
- data/lib/simple_a2a/server/base.rb +43 -0
- data/lib/simple_a2a/server/context.rb +44 -0
- data/lib/simple_a2a/server/event_router.rb +50 -0
- data/lib/simple_a2a/server/falcon_runner.rb +31 -0
- data/lib/simple_a2a/server/multi_agent.rb +50 -0
- data/lib/simple_a2a/server/push_sender.rb +80 -0
- data/lib/simple_a2a/server/resume_context.rb +14 -0
- data/lib/simple_a2a/storage/base.rb +12 -0
- data/lib/simple_a2a/storage/memory.rb +41 -0
- data/lib/simple_a2a/version.rb +5 -0
- data/lib/simple_a2a.rb +49 -0
- data/mkdocs.yml +143 -0
- data/sig/simple_a2a.rbs +4 -0
- metadata +353 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Streaming Demo
|
|
2
|
+
|
|
3
|
+
`examples/02_streaming` demonstrates `tasks/sendSubscribe` and Server-Sent Events (SSE). The server streams an article word by word, and the client prints chunks as they arrive.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `examples/02_streaming/server.rb` | Defines `StreamingExecutor`, advertises streaming support, and emits status and artifact events |
|
|
10
|
+
| `examples/02_streaming/client.rb` | Uses `A2A.sse_client` and `send_subscribe` to consume streamed events |
|
|
11
|
+
|
|
12
|
+
## Run it
|
|
13
|
+
|
|
14
|
+
From the repository root:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle exec ruby examples/run 02_streaming
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Manual run:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle exec ruby examples/02_streaming/server.rb
|
|
24
|
+
bundle exec ruby examples/02_streaming/client.rb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Server behavior
|
|
28
|
+
|
|
29
|
+
The streaming executor starts the task, emits a working status, sends artifact chunks, completes the task, and emits a final status.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
def call(ctx)
|
|
33
|
+
ctx.task.start!
|
|
34
|
+
ctx.emit_status
|
|
35
|
+
|
|
36
|
+
WORDS.each_with_index do |word, i|
|
|
37
|
+
text = i.zero? ? word : " #{word}"
|
|
38
|
+
|
|
39
|
+
artifact = A2A::Models::Artifact.new(
|
|
40
|
+
index: 0,
|
|
41
|
+
parts: [A2A::Models::Part.text(text)],
|
|
42
|
+
append: i > 0,
|
|
43
|
+
last_chunk: i == WORDS.length - 1
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ctx.emit_artifact(artifact, append: i > 0, last_chunk: i == WORDS.length - 1)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
ctx.task.complete!
|
|
50
|
+
ctx.emit_status(final: true)
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The agent card declares streaming support:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
A2A::Models::AgentCapabilities.new(streaming: true)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Client behavior
|
|
61
|
+
|
|
62
|
+
The client uses `A2A.sse_client` instead of `A2A.client`:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
client = A2A.sse_client(url: "http://localhost:9292")
|
|
66
|
+
|
|
67
|
+
client.send_subscribe(message: A2A::Models::Message.user("stream")) do |event|
|
|
68
|
+
case event
|
|
69
|
+
when A2A::Models::TaskStatusUpdateEvent
|
|
70
|
+
# task state changed
|
|
71
|
+
when A2A::Models::TaskArtifactUpdateEvent
|
|
72
|
+
print event.artifact.parts.map(&:text).join
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It also tracks event count, word count, elapsed time, and effective words per minute, which makes it useful for checking end-to-end streaming behavior.
|
|
78
|
+
|
|
79
|
+
## Relationship to the guide
|
|
80
|
+
|
|
81
|
+
The [Streaming Responses guide](../guides/streaming.md) explains the API in isolation. This demo shows the same flow as a runnable pair of scripts.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
## Requirements
|
|
4
|
+
|
|
5
|
+
- Ruby 3.2 or higher (tested on Ruby 4.0)
|
|
6
|
+
- Bundler 2.x
|
|
7
|
+
|
|
8
|
+
## Gemfile
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "simple_a2a"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then run:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Direct install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
gem install simple_a2a
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Dependencies
|
|
27
|
+
|
|
28
|
+
`simple_a2a` pulls in the following gems automatically:
|
|
29
|
+
|
|
30
|
+
| Gem | Purpose |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `async` | Async fiber runtime |
|
|
33
|
+
| `async-http` | Non-blocking HTTP client (used by `Client::Base` and `Client::SSE`) |
|
|
34
|
+
| `falcon` | Async-native Rack HTTP server |
|
|
35
|
+
| `roda` | Rack router for the server endpoint |
|
|
36
|
+
| `rack` | WSGI-style Ruby web interface |
|
|
37
|
+
| `zeitwerk` | Autoloading |
|
|
38
|
+
| `jwt` | RS256 JWT signing for push notification webhooks |
|
|
39
|
+
| `logger` | Ruby standard logger (bundled gem in Ruby 4.0+) |
|
|
40
|
+
| `simple_flow` | Pipeline composition for executor chains |
|
|
41
|
+
| `typed_bus` | Per-task SSE event fan-out |
|
|
42
|
+
|
|
43
|
+
## Verifying the install
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
require "simple_a2a"
|
|
47
|
+
puts A2A::VERSION # => "0.1.0"
|
|
48
|
+
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Quick Start
|
|
2
|
+
|
|
3
|
+
This guide walks through building a minimal echo agent and a client that talks to it.
|
|
4
|
+
|
|
5
|
+
## 1. Create the executor
|
|
6
|
+
|
|
7
|
+
An **executor** contains your agent's logic. Subclass `A2A::Server::AgentExecutor` and implement `#call`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "simple_a2a"
|
|
11
|
+
|
|
12
|
+
class EchoExecutor < A2A::Server::AgentExecutor
|
|
13
|
+
def call(ctx)
|
|
14
|
+
input = ctx.message.text_content
|
|
15
|
+
ctx.task.complete!(artifacts: [
|
|
16
|
+
A2A::Models::Artifact.new(
|
|
17
|
+
name: "reply",
|
|
18
|
+
parts: [A2A::Models::Part.text("Echo: #{input}")]
|
|
19
|
+
)
|
|
20
|
+
])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`ctx` is an `A2A::Server::Context` that gives you access to the incoming message, the task object, and helpers for emitting streaming events.
|
|
26
|
+
|
|
27
|
+
## 2. Build the agent card
|
|
28
|
+
|
|
29
|
+
The **AgentCard** describes your agent to clients:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
card = A2A::Models::AgentCard.new(
|
|
33
|
+
name: "EchoAgent",
|
|
34
|
+
version: "1.0",
|
|
35
|
+
capabilities: A2A::Models::AgentCapabilities.new,
|
|
36
|
+
skills: [A2A::Models::AgentSkill.new(name: "echo", description: "Echoes your input")],
|
|
37
|
+
interfaces: [A2A::Models::AgentInterface.new(
|
|
38
|
+
type: "json-rpc",
|
|
39
|
+
url: "http://localhost:9292",
|
|
40
|
+
version: "1.0"
|
|
41
|
+
)]
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 3. Start the server
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
server = A2A.server(agent_card: card, executor: EchoExecutor.new)
|
|
49
|
+
server.run # starts Falcon on localhost:9292
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or with custom host/port:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
server = A2A::Server::Base.new(
|
|
56
|
+
agent_card: card,
|
|
57
|
+
executor: EchoExecutor.new,
|
|
58
|
+
host: "0.0.0.0",
|
|
59
|
+
port: 8080
|
|
60
|
+
)
|
|
61
|
+
server.run
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 4. Send a task from a client
|
|
65
|
+
|
|
66
|
+
In a separate process or script:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
require "simple_a2a"
|
|
70
|
+
|
|
71
|
+
client = A2A.client(url: "http://localhost:9292")
|
|
72
|
+
|
|
73
|
+
task = client.send_task(message: A2A::Models::Message.user("hello there"))
|
|
74
|
+
puts task.status.state # => "completed"
|
|
75
|
+
puts task.artifacts.first.parts.first.text # => "Echo: hello there"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 5. Discover the agent card
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
card = client.agent_card
|
|
82
|
+
puts card.name # => "EchoAgent"
|
|
83
|
+
puts card.version # => "1.0"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 6. List and retrieve tasks
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
tasks = client.list_tasks
|
|
90
|
+
task = client.get_task(tasks.first.id)
|
|
91
|
+
puts task.id
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Next steps
|
|
95
|
+
|
|
96
|
+
- [Architecture overview](../architecture/index.md) — understand the components
|
|
97
|
+
- [Streaming responses](../guides/streaming.md) — emit incremental SSE events
|
|
98
|
+
- [Runnable examples](../examples/index.md) - run the demo apps in `examples/`
|
|
99
|
+
- [Push notifications](../guides/push-notifications.md) — webhook delivery
|
|
100
|
+
- [API reference](../api/index.md) — full class and method docs
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Custom Storage
|
|
2
|
+
|
|
3
|
+
The default `Storage::Memory` is ephemeral — tasks are lost on restart. For production, implement `Storage::Base`.
|
|
4
|
+
|
|
5
|
+
## Interface
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class A2A::Storage::Base
|
|
9
|
+
def save(task) = raise NotImplementedError
|
|
10
|
+
def find(id) = raise NotImplementedError # nil if missing
|
|
11
|
+
def find!(id) = raise NotImplementedError # raises TaskNotFoundError if missing
|
|
12
|
+
def delete(id) = raise NotImplementedError
|
|
13
|
+
def list = raise NotImplementedError # returns Array
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Minimal example
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class HashStorage < A2A::Storage::Base
|
|
21
|
+
def initialize
|
|
22
|
+
@store = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def save(task)
|
|
26
|
+
@store[task.id] = task
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find(id)
|
|
30
|
+
@store[id]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def find!(id)
|
|
34
|
+
@store.fetch(id) { raise A2A::TaskNotFoundError, "Task #{id} not found" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(id)
|
|
38
|
+
@store.delete(id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list
|
|
42
|
+
@store.values
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Serialization
|
|
48
|
+
|
|
49
|
+
`A2A::Models::Task` supports round-trip serialization via `to_h` and `from_hash`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
hash = task.to_h # => { "id" => "…", "status" => {…}, … }
|
|
53
|
+
task = A2A::Models::Task.from_hash(hash) # => reconstructed Task
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use this for any storage backend that persists JSON (databases, Redis, files):
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
def save(task)
|
|
60
|
+
db.set(task.id, task.to_h.to_json)
|
|
61
|
+
task
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find(id)
|
|
65
|
+
raw = db.get(id)
|
|
66
|
+
return nil unless raw
|
|
67
|
+
A2A::Models::Task.from_hash(JSON.parse(raw))
|
|
68
|
+
end
|
|
69
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Push Notifications
|
|
2
|
+
|
|
3
|
+
Push notifications let an A2A server deliver task events to a client-registered webhook, rather than requiring the client to maintain an open SSE connection.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
1. Client registers a webhook URL via `tasks/pushNotification/set`
|
|
8
|
+
2. Server delivers `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent` to the URL
|
|
9
|
+
3. Requests are signed with RS256 JWT (optional but recommended)
|
|
10
|
+
|
|
11
|
+
## Server setup
|
|
12
|
+
|
|
13
|
+
Create a `PushSender` with an RS256 private key:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "openssl"
|
|
17
|
+
|
|
18
|
+
private_key = OpenSSL::PKey::RSA.generate(2048)
|
|
19
|
+
|
|
20
|
+
push_sender = A2A::Server::PushSender.new(
|
|
21
|
+
private_key: private_key,
|
|
22
|
+
key_id: "my-key-2026",
|
|
23
|
+
issuer: "my-agent"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
server = A2A::Server::Base.new(
|
|
27
|
+
agent_card: card,
|
|
28
|
+
executor: MyExecutor.new,
|
|
29
|
+
push_sender: push_sender
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Advertise push notification support in your AgentCard:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
capabilities = A2A::Models::AgentCapabilities.new(
|
|
37
|
+
push_notifications: true
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Delivering events from your executor
|
|
42
|
+
|
|
43
|
+
Call `push_sender.deliver` with the stored `PushNotificationConfig` and an event:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class MyExecutor < A2A::Server::AgentExecutor
|
|
47
|
+
def initialize(push_sender:)
|
|
48
|
+
@push_sender = push_sender
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(ctx)
|
|
52
|
+
ctx.task.complete!(artifacts: [ … ])
|
|
53
|
+
event = A2A::Models::TaskStatusUpdateEvent.new(
|
|
54
|
+
task_id: ctx.task.id,
|
|
55
|
+
context_id: ctx.task.context_id,
|
|
56
|
+
status: ctx.task.status,
|
|
57
|
+
final: true
|
|
58
|
+
)
|
|
59
|
+
config = # retrieve PushNotificationConfig registered by the client
|
|
60
|
+
@push_sender.deliver(config, event)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`deliver` returns `true` on HTTP 2xx, `false` on failure. Failures are logged via `A2A.logger` but not raised.
|
|
66
|
+
|
|
67
|
+
## Authentication schemes
|
|
68
|
+
|
|
69
|
+
### Bearer (RS256 JWT)
|
|
70
|
+
|
|
71
|
+
A JWT is generated and sent as `Authorization: Bearer <token>`. The token payload includes:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"iss": "my-agent",
|
|
76
|
+
"iat": 1700000000,
|
|
77
|
+
"exp": 1700000300,
|
|
78
|
+
"payload_hash": "<SHA-256 hex of the request body>"
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
auth = A2A::Models::AuthenticationInfo.new(scheme: "bearer")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Static token
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
auth = A2A::Models::AuthenticationInfo.new(
|
|
90
|
+
scheme: "token",
|
|
91
|
+
value: "secret-webhook-token",
|
|
92
|
+
header_name: "X-Webhook-Token" # optional, defaults to "Authorization"
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Sent as `X-Webhook-Token: Token secret-webhook-token`.
|
|
97
|
+
|
|
98
|
+
## PushSender without a private key
|
|
99
|
+
|
|
100
|
+
If no `private_key` is provided, the JWT token is the literal string `"no-key"`. This is useful for local development without RSA setup:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
push_sender = A2A::Server::PushSender.new # no args
|
|
104
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Streaming Responses
|
|
2
|
+
|
|
3
|
+
`tasks/sendSubscribe` keeps the HTTP connection open and streams events as your executor progresses. This is ideal for long-running tasks where the client needs incremental feedback.
|
|
4
|
+
|
|
5
|
+
## Server side — emitting events
|
|
6
|
+
|
|
7
|
+
Use `ctx.emit_status` and `ctx.emit_artifact` inside your executor:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class StreamingExecutor < A2A::Server::AgentExecutor
|
|
11
|
+
def call(ctx)
|
|
12
|
+
ctx.task.start!
|
|
13
|
+
ctx.emit_status # publishes TaskStatusUpdateEvent(state: "working", final: false)
|
|
14
|
+
|
|
15
|
+
# Stream artifact chunks
|
|
16
|
+
["Thinking… ", "Processing… ", "Done!"].each_with_index do |chunk, i|
|
|
17
|
+
last = i == 2
|
|
18
|
+
artifact = A2A::Models::Artifact.new(
|
|
19
|
+
index: 0,
|
|
20
|
+
parts: [A2A::Models::Part.text(chunk)],
|
|
21
|
+
append: i > 0, # true for chunks after the first
|
|
22
|
+
last_chunk: last
|
|
23
|
+
)
|
|
24
|
+
ctx.emit_artifact(artifact, append: i > 0, last_chunk: last)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
ctx.task.complete!
|
|
28
|
+
ctx.emit_status(final: true) # signals end of stream
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Emit methods
|
|
34
|
+
|
|
35
|
+
| Method | Publishes | `final` |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `ctx.emit_status` | `TaskStatusUpdateEvent` | pass `final: true` to close the stream |
|
|
38
|
+
| `ctx.emit_artifact(artifact, append:, last_chunk:)` | `TaskArtifactUpdateEvent` | always `false` |
|
|
39
|
+
|
|
40
|
+
Always emit `ctx.emit_status(final: true)` as your last event to close the SSE connection cleanly.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Client side — consuming events
|
|
45
|
+
|
|
46
|
+
Use `Client::SSE#send_subscribe`:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
client = A2A.sse_client(url: "http://localhost:9292")
|
|
50
|
+
|
|
51
|
+
client.send_subscribe(message: A2A::Models::Message.user("go")) do |event|
|
|
52
|
+
case event
|
|
53
|
+
when A2A::Models::TaskStatusUpdateEvent
|
|
54
|
+
puts "[status] #{event.status.state} final=#{event.final}"
|
|
55
|
+
break if event.final
|
|
56
|
+
when A2A::Models::TaskArtifactUpdateEvent
|
|
57
|
+
print event.artifact.parts.map(&:text).join
|
|
58
|
+
$stdout.flush
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The block is called for each parsed SSE event. Unrecognized event types yield a plain `Hash`.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## AgentCard declaration
|
|
68
|
+
|
|
69
|
+
Advertise streaming support in your AgentCard:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
capabilities = A2A::Models::AgentCapabilities.new(streaming: true)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Clients can check `card.capabilities.streaming` before using `send_subscribe`.
|
data/docs/index.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# simple_a2a
|
|
2
|
+
|
|
3
|
+
A Ruby gem implementing the [Agent2Agent (A2A) protocol](https://a2a-protocol.org/latest/) — an open standard by Google and the Linux Foundation for interoperability between AI agents.
|
|
4
|
+
|
|
5
|
+
`simple_a2a` provides a complete A2A client and server in a single package, built on the async Ruby ecosystem with [Falcon](https://github.com/socketry/falcon) as the recommended HTTP server.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What is A2A?
|
|
10
|
+
|
|
11
|
+
The Agent2Agent (A2A) protocol defines how AI agents running on different platforms, frameworks, and vendors can discover each other, exchange tasks, and stream results — without vendor lock-in.
|
|
12
|
+
|
|
13
|
+
- Agents expose a JSON-RPC 2.0 over HTTP endpoint
|
|
14
|
+
- Clients send tasks and receive structured results
|
|
15
|
+
- Streaming uses Server-Sent Events (SSE)
|
|
16
|
+
- Push notifications use webhooks (RS256 JWT)
|
|
17
|
+
- AgentCards describe capabilities and skills
|
|
18
|
+
|
|
19
|
+
**Protocol Reference:** [https://a2a-protocol.org/latest/](https://a2a-protocol.org/latest/)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## At a Glance
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "simple_a2a"
|
|
27
|
+
|
|
28
|
+
# 1. Implement your agent logic
|
|
29
|
+
class MyExecutor < A2A::Server::AgentExecutor
|
|
30
|
+
def call(ctx)
|
|
31
|
+
input = ctx.message.text_content
|
|
32
|
+
ctx.task.complete!(artifacts: [
|
|
33
|
+
A2A::Models::Artifact.new(
|
|
34
|
+
parts: [A2A::Models::Part.text("You said: #{input}")]
|
|
35
|
+
)
|
|
36
|
+
])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 2. Describe your agent
|
|
41
|
+
card = A2A::Models::AgentCard.new(
|
|
42
|
+
name: "MyAgent",
|
|
43
|
+
version: "1.0",
|
|
44
|
+
capabilities: A2A::Models::AgentCapabilities.new,
|
|
45
|
+
skills: [A2A::Models::AgentSkill.new(name: "reply")],
|
|
46
|
+
interfaces: [A2A::Models::AgentInterface.new(
|
|
47
|
+
type: "json-rpc", url: "http://localhost:9292", version: "1.0"
|
|
48
|
+
)]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 3. Start the server
|
|
52
|
+
A2A.server(agent_card: card, executor: MyExecutor.new).run
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# Client — send a task to any A2A agent
|
|
57
|
+
client = A2A.client(url: "http://localhost:9292")
|
|
58
|
+
task = client.send_task(message: A2A::Models::Message.user("hello"))
|
|
59
|
+
puts task.status.state # => "completed"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
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
|
+
## Runnable demos
|
|
82
|
+
|
|
83
|
+
The repository includes three demo applications under `examples/`:
|
|
84
|
+
|
|
85
|
+
| Demo | Shows |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `01_basic_usage` | Agent discovery, `tasks/send`, task listing, task lookup, and error handling |
|
|
88
|
+
| `02_streaming` | `tasks/sendSubscribe` with Server-Sent Events and incremental artifact chunks |
|
|
89
|
+
| `03_llm_research` | Multi-agent routing, parallel streaming LLM calls, evaluator agent, and a Sinatra web client |
|
|
90
|
+
|
|
91
|
+
Run the basic and streaming demos end-to-end with:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
bundle exec ruby examples/run 01_basic_usage
|
|
95
|
+
bundle exec ruby examples/run 02_streaming
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The LLM research demo requires `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and its demo-specific gems. See the [examples overview](examples/index.md) for setup details.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/01_basic_usage/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/01_basic_usage/server.rb
|
|
8
|
+
|
|
9
|
+
require_relative "../common_config"
|
|
10
|
+
|
|
11
|
+
URL = "http://localhost:9292"
|
|
12
|
+
|
|
13
|
+
client = A2A.client(url: URL)
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# 1. Discover the agent
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
puts "=== Agent Card ==="
|
|
19
|
+
card = client.agent_card
|
|
20
|
+
puts " Name: #{card.name}"
|
|
21
|
+
puts " Version: #{card.version}"
|
|
22
|
+
puts " Description: #{card.description}"
|
|
23
|
+
puts " Skills: #{card.skills.map(&:name).join(', ')}"
|
|
24
|
+
puts
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# 2. Send a few tasks
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
messages = [
|
|
30
|
+
"world",
|
|
31
|
+
"Ruby developers",
|
|
32
|
+
"A2A protocol"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
puts "=== Sending Tasks ==="
|
|
36
|
+
task_ids = messages.map do |text|
|
|
37
|
+
task = client.send_task(message: A2A::Models::Message.user(text))
|
|
38
|
+
|
|
39
|
+
reply = task.artifacts.first&.parts&.first&.text || "(no reply)"
|
|
40
|
+
puts " [#{task.status.state}] sent: #{text.inspect}"
|
|
41
|
+
puts " got: #{reply.inspect}"
|
|
42
|
+
task.id
|
|
43
|
+
end
|
|
44
|
+
puts
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# 3. List all tasks
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
puts "=== Task List ==="
|
|
50
|
+
all_tasks = client.list_tasks
|
|
51
|
+
all_tasks.each do |t|
|
|
52
|
+
puts " #{t.id} state=#{t.status.state}"
|
|
53
|
+
end
|
|
54
|
+
puts " Total: #{all_tasks.size}"
|
|
55
|
+
puts
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# 4. Retrieve a single task by ID
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
puts "=== Retrieve Task ==="
|
|
61
|
+
retrieved = client.get_task(task_ids.first)
|
|
62
|
+
puts " id: #{retrieved.id}"
|
|
63
|
+
puts " state: #{retrieved.status.state}"
|
|
64
|
+
puts " reply: #{retrieved.artifacts.first&.parts&.first&.text.inspect}"
|
|
65
|
+
puts
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# 5. Cancel a non-existent task (demonstrates error handling)
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
puts "=== Error Handling ==="
|
|
71
|
+
begin
|
|
72
|
+
client.get_task("no-such-task-id")
|
|
73
|
+
rescue A2A::Error => e
|
|
74
|
+
puts " Caught expected error: #{e.message}"
|
|
75
|
+
end
|