robot_lab-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/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.rubocop.yml +26 -0
- data/CHANGELOG.md +24 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +106 -0
- data/Rakefile +104 -0
- data/docs/assets/images/architecture.png +0 -0
- data/docs/assets/images/architecture.svg +258 -0
- data/docs/examples.md +116 -0
- data/docs/getting-started.md +103 -0
- data/docs/index.md +23 -0
- data/docs/interactive-modes.md +104 -0
- data/docs/server-api.md +118 -0
- data/examples/01_sync_robot/client.rb +94 -0
- data/examples/01_sync_robot/server.rb +45 -0
- data/examples/02_interactive_a2a_tool/client.rb +144 -0
- data/examples/02_interactive_a2a_tool/server.rb +78 -0
- data/examples/03_robot_network/client.rb +83 -0
- data/examples/03_robot_network/server.rb +77 -0
- data/examples/04_io_bridge/client.rb +140 -0
- data/examples/04_io_bridge/server.rb +64 -0
- data/examples/05_multi_agent/client.rb +97 -0
- data/examples/05_multi_agent/server.rb +76 -0
- data/examples/06_rack_mount/client.rb +90 -0
- data/examples/06_rack_mount/config.ru +44 -0
- data/examples/06_rack_mount/server.rb +72 -0
- data/examples/common_config.rb +9 -0
- data/examples/run +112 -0
- data/lib/robot_lab/a2a/ask_user_tool.rb +43 -0
- data/lib/robot_lab/a2a/io_bridge.rb +75 -0
- data/lib/robot_lab/a2a/network_adapter.rb +38 -0
- data/lib/robot_lab/a2a/registry.rb +36 -0
- data/lib/robot_lab/a2a/robot_adapter.rb +183 -0
- data/lib/robot_lab/a2a/server.rb +128 -0
- data/lib/robot_lab/a2a/version.rb +7 -0
- data/lib/robot_lab/a2a.rb +39 -0
- data/mkdocs.yml +153 -0
- metadata +128 -0
data/docs/server-api.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Server API Reference
|
|
2
|
+
|
|
3
|
+
`RobotLab::A2A::Server` is a fluent builder. Every mutating method returns `self` so calls can be chained.
|
|
4
|
+
|
|
5
|
+
## Constructor
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
RobotLab::A2A::Server.new(
|
|
9
|
+
host: "localhost", # String — bind address
|
|
10
|
+
port: 9292, # Integer — TCP port
|
|
11
|
+
storage: nil, # Object — task storage backend (simple_a2a compatible); nil uses in-memory default
|
|
12
|
+
interactive: :none # Symbol — default interactive mode for all registered adapters
|
|
13
|
+
)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Parameter | Type | Default | Notes |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| `host` | `String` | `"localhost"` | Passed through to `simple_a2a` / Falcon |
|
|
19
|
+
| `port` | `Integer` | `9292` | TCP port to listen on |
|
|
20
|
+
| `storage` | Object / nil | `nil` | Pluggable task persistence; defaults to an in-memory store |
|
|
21
|
+
| `interactive` | `Symbol` | `:none` | Applies to all adapters unless overridden per-registration |
|
|
22
|
+
|
|
23
|
+
Valid `interactive` values: `:none`, `:a2a_tool`, `:io_bridge`. See [Interactive Modes](interactive-modes.md) for details.
|
|
24
|
+
|
|
25
|
+
## `add_robot`
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
server.add_robot(
|
|
29
|
+
robot,
|
|
30
|
+
name: required, # String — human-readable agent name
|
|
31
|
+
description: required, # String — agent capability description
|
|
32
|
+
path: nil # String — URL path segment; derived from name if nil
|
|
33
|
+
) → self
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Wraps `robot` in a `RobotAdapter` using the server's `interactive` setting and registers it with `simple_a2a`.
|
|
37
|
+
|
|
38
|
+
**`robot` must respond to:**
|
|
39
|
+
|
|
40
|
+
- `robot.name` → `String`
|
|
41
|
+
- `robot.description` → `String`
|
|
42
|
+
- `robot.run(text)` → object responding to `.reply`
|
|
43
|
+
- For `:a2a_tool`: `robot.local_tools` → mutable `Array` (backed by `@local_tools`)
|
|
44
|
+
- For `:io_bridge`: `robot.input=` and `robot.output=`
|
|
45
|
+
|
|
46
|
+
**Path defaulting:** If `path:` is omitted, it is derived from `name` by applying the DNS label conversion rule (see below). Example: `name: "My Robot"` → path `/my-robot`.
|
|
47
|
+
|
|
48
|
+
## `add_network`
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
server.add_network(
|
|
52
|
+
network,
|
|
53
|
+
name: required, # String — human-readable agent name
|
|
54
|
+
description: required, # String — agent capability description
|
|
55
|
+
path: nil # String — URL path segment; derived from name if nil
|
|
56
|
+
) → self
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Wraps `network` in a `NetworkAdapter`. Only `:none` mode is supported for networks; the `interactive` setting is ignored.
|
|
60
|
+
|
|
61
|
+
**`network` must respond to:**
|
|
62
|
+
|
|
63
|
+
- `network.run(message: text)` → object responding to `.last_text_content`
|
|
64
|
+
|
|
65
|
+
**Path defaulting:** Same DNS label conversion as `add_robot`.
|
|
66
|
+
|
|
67
|
+
## `add_robot` — optional keyword details
|
|
68
|
+
|
|
69
|
+
`name:` and `description:` default to `robot.name` and `robot.description` when omitted. `path:` always defaults to the DNS label of the name used.
|
|
70
|
+
|
|
71
|
+
## `run`
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
server.run
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Starts the Falcon HTTP server (blocking). Host and port are set in the constructor. Does not return until the server is stopped.
|
|
78
|
+
|
|
79
|
+
## `to_app`
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
app = server.to_app # → Rack::URLMap
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Returns a `Rack::URLMap` instead of starting a server. Use this to embed `robot_lab-a2a` inside an existing Rack application such as Rails or Puma.
|
|
86
|
+
|
|
87
|
+
Example `config.ru`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
require "robot_lab/a2a"
|
|
91
|
+
require_relative "my_robot"
|
|
92
|
+
|
|
93
|
+
robot = MyRobot.new
|
|
94
|
+
a2a = RobotLab::A2A::Server.new
|
|
95
|
+
.add_robot(robot, name: "Assistant", description: "General assistant")
|
|
96
|
+
|
|
97
|
+
run Rack::URLMap.new(
|
|
98
|
+
"/" => MyRailsApp,
|
|
99
|
+
"/a2a" => a2a.to_app
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## DNS label conversion rule
|
|
104
|
+
|
|
105
|
+
Agent names are converted to RFC 1123-compatible path segments:
|
|
106
|
+
|
|
107
|
+
1. Convert to lowercase.
|
|
108
|
+
2. Replace underscores with hyphens.
|
|
109
|
+
3. Drop any character that is not an ASCII letter, digit, or hyphen.
|
|
110
|
+
4. Strip leading/trailing hyphens.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
|
|
114
|
+
| Name | Path |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `"My Robot"` | `/my-robot` |
|
|
117
|
+
| `"order_processor"` | `/order-processor` |
|
|
118
|
+
| `"GPT-4 Agent!"` | `/gpt-4-agent` |
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/01_sync_robot/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/01_sync_robot/server.rb
|
|
8
|
+
#
|
|
9
|
+
# Or run both together:
|
|
10
|
+
# bundle exec ruby examples/run 01_sync_robot
|
|
11
|
+
#
|
|
12
|
+
# What this demo shows:
|
|
13
|
+
#
|
|
14
|
+
# Discovery — client.agent_card fetches the agent's name, description, and
|
|
15
|
+
# skills declared by the robot and published over A2A.
|
|
16
|
+
#
|
|
17
|
+
# Task send — client.send_task dispatches a user message and returns a
|
|
18
|
+
# completed task whose artifact holds the robot's reply.
|
|
19
|
+
#
|
|
20
|
+
# Task list — client.list_tasks returns all tasks the agent has processed,
|
|
21
|
+
# demonstrating the A2A task-store query interface.
|
|
22
|
+
#
|
|
23
|
+
# Task get — client.get_task(id) retrieves a specific completed task by ID,
|
|
24
|
+
# confirming the reply is durable and addressable after the fact.
|
|
25
|
+
|
|
26
|
+
require_relative '../common_config'
|
|
27
|
+
|
|
28
|
+
URL = 'http://localhost:9292/echo-robot'
|
|
29
|
+
|
|
30
|
+
client = A2A.client(url: URL)
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# 1. Discover the agent
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
puts '=== Agent Card ==='
|
|
36
|
+
card = client.agent_card
|
|
37
|
+
puts <<~HEREDOC
|
|
38
|
+
Name: #{card.name}
|
|
39
|
+
Description: #{card.description}
|
|
40
|
+
Skills: #{card.skills.map(&:name).join(', ')}
|
|
41
|
+
|
|
42
|
+
HEREDOC
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# 2. Send tasks and inspect replies
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
messages = [
|
|
48
|
+
'hello from robot_lab-a2a',
|
|
49
|
+
'the agent2agent protocol',
|
|
50
|
+
'RobotLab makes multi-robot workflows easy'
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
puts '=== Sending Tasks ==='
|
|
54
|
+
task_ids = messages.map do |text|
|
|
55
|
+
task = client.send_task(message: A2A::Models::Message.user(text))
|
|
56
|
+
reply = task.artifacts.first&.parts&.first&.text || '(no reply)'
|
|
57
|
+
puts <<~HEREDOC
|
|
58
|
+
[#{task.status.state}]
|
|
59
|
+
sent: #{text.inspect}
|
|
60
|
+
got: #{reply.inspect}
|
|
61
|
+
|
|
62
|
+
HEREDOC
|
|
63
|
+
task.id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# 3. List all tasks
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
puts '=== Task List ==='
|
|
70
|
+
all_tasks = client.list_tasks
|
|
71
|
+
all_tasks.each { |t| puts " #{t.id} state=#{t.status.state}" }
|
|
72
|
+
puts " Total: #{all_tasks.size}"
|
|
73
|
+
puts
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# 4. Retrieve a task by ID
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
puts '=== Retrieve Task ==='
|
|
79
|
+
retrieved = client.get_task(task_ids.first)
|
|
80
|
+
puts <<~HEREDOC
|
|
81
|
+
id: #{retrieved.id}
|
|
82
|
+
state: #{retrieved.status.state}
|
|
83
|
+
reply: #{retrieved.artifacts.first&.parts&.first&.text.inspect}
|
|
84
|
+
|
|
85
|
+
HEREDOC
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# 5. Verify
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
puts '=== Verification ==='
|
|
91
|
+
all_completed = task_ids.all? do |id|
|
|
92
|
+
client.get_task(id).status.state == 'completed'
|
|
93
|
+
end
|
|
94
|
+
puts(all_completed ? 'All tasks completed. PASS' : 'Some tasks did not complete. FAIL')
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/01_sync_robot/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Wraps a plain Ruby robot object as an A2A agent in :none (synchronous) mode.
|
|
7
|
+
# The robot receives the task message, processes it, and returns a reply in a
|
|
8
|
+
# single call — no interactive turns, no LLM required.
|
|
9
|
+
#
|
|
10
|
+
# Swap EchoRobot for a real RobotLab.build(...) robot to connect a live LLM.
|
|
11
|
+
|
|
12
|
+
require_relative '../common_config'
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Robot — any object that responds to #name, #description, and #run(input).
|
|
16
|
+
# run() must return an object with a #reply method.
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
class EchoRobot
|
|
19
|
+
Result = Struct.new(:reply)
|
|
20
|
+
|
|
21
|
+
def name = 'echo_robot'
|
|
22
|
+
def description = 'Transforms every message: upcases it and reports character count'
|
|
23
|
+
|
|
24
|
+
def run(input)
|
|
25
|
+
text = input.strip
|
|
26
|
+
Result.new("#{text.upcase} [#{text.length} chars, processed by EchoRobot]")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Build and start the A2A server.
|
|
32
|
+
#
|
|
33
|
+
# RobotLab::A2A::Server wraps each robot in a RobotAdapter, builds an AgentCard
|
|
34
|
+
# from the robot's name and description, and delegates HTTP serving to simple_a2a.
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
server = RobotLab::A2A::Server.new(host: 'localhost', port: 9292)
|
|
37
|
+
server.add_robot(EchoRobot.new)
|
|
38
|
+
|
|
39
|
+
puts <<~HEREDOC
|
|
40
|
+
Starting EchoRobot on http://localhost:9292/echo-robot
|
|
41
|
+
Press Ctrl-C to stop.
|
|
42
|
+
|
|
43
|
+
HEREDOC
|
|
44
|
+
|
|
45
|
+
server.run
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/02_interactive_a2a_tool/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/02_interactive_a2a_tool/server.rb
|
|
8
|
+
#
|
|
9
|
+
# Or run both together:
|
|
10
|
+
# bundle exec ruby examples/run 02_interactive_a2a_tool
|
|
11
|
+
#
|
|
12
|
+
# What this demo shows:
|
|
13
|
+
#
|
|
14
|
+
# Turn 1 — client sends an initial topic.
|
|
15
|
+
# The robot calls AskUserTool with question:, choices:, and default:,
|
|
16
|
+
# which suspends the robot thread and returns an input_required task.
|
|
17
|
+
# The status message contains the formatted prompt — question text,
|
|
18
|
+
# a numbered choice list, and the default value.
|
|
19
|
+
#
|
|
20
|
+
# Turn 2 — client reads the prompt and sends a follow-up with task_id: pointing
|
|
21
|
+
# at the same task. The adapter resumes the robot thread with the
|
|
22
|
+
# answer, the robot completes, and the task reaches "completed".
|
|
23
|
+
#
|
|
24
|
+
# Note: Turn 2 requires simple_a2a >= 0.3.1 to route the follow-up to the
|
|
25
|
+
# waiting robot thread via a ResumeContext. With earlier versions Turn 1 still
|
|
26
|
+
# demonstrates the input_required suspension.
|
|
27
|
+
|
|
28
|
+
require_relative '../common_config'
|
|
29
|
+
|
|
30
|
+
URL = 'http://localhost:9292/personal-greeter'
|
|
31
|
+
|
|
32
|
+
client = A2A.client(url: URL)
|
|
33
|
+
|
|
34
|
+
def divider = puts('─' * 60)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Discover the agent
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
puts
|
|
40
|
+
puts '=== Agent Card ==='
|
|
41
|
+
card = client.agent_card
|
|
42
|
+
puts <<~HEREDOC
|
|
43
|
+
Name: #{card.name}
|
|
44
|
+
Description: #{card.description}
|
|
45
|
+
|
|
46
|
+
HEREDOC
|
|
47
|
+
|
|
48
|
+
divider
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Turn 1 — initial request; expect input_required
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
puts <<~HEREDOC
|
|
54
|
+
|
|
55
|
+
=== Turn 1: Initial request ===
|
|
56
|
+
Sending topic: 'the A2A protocol'
|
|
57
|
+
|
|
58
|
+
HEREDOC
|
|
59
|
+
|
|
60
|
+
task1 = client.send_task(message: A2A::Models::Message.user('the A2A protocol'))
|
|
61
|
+
|
|
62
|
+
state1 = task1.status.state
|
|
63
|
+
question = task1.status.message&.parts&.first&.text
|
|
64
|
+
|
|
65
|
+
puts " Task ID: #{task1.id}"
|
|
66
|
+
puts " State: #{state1}"
|
|
67
|
+
puts " Question: #{question}" if question
|
|
68
|
+
puts
|
|
69
|
+
|
|
70
|
+
if state1 != 'input_required'
|
|
71
|
+
puts "Expected input_required — got #{state1.inspect}."
|
|
72
|
+
puts 'The robot may not have an AskUserTool injected (check server interactive: mode).'
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
divider
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Turn 2 — answer the robot's question using the same task ID
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
puts <<~HEREDOC
|
|
82
|
+
|
|
83
|
+
=== Turn 2: Answer ===
|
|
84
|
+
Answering: 'Alice'
|
|
85
|
+
|
|
86
|
+
HEREDOC
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
task2 = client.send_task(
|
|
90
|
+
message: A2A::Models::Message.user('Alice'),
|
|
91
|
+
task_id: task1.id
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
state2 = task2.status.state
|
|
95
|
+
reply = task2.artifacts&.first&.parts&.first&.text
|
|
96
|
+
new_task = task2.id != task1.id # simple_a2a created a fresh task instead of resuming
|
|
97
|
+
|
|
98
|
+
puts " Task ID: #{task2.id}"
|
|
99
|
+
puts " State: #{state2}"
|
|
100
|
+
puts " Reply: #{reply}" if reply
|
|
101
|
+
puts
|
|
102
|
+
|
|
103
|
+
divider
|
|
104
|
+
puts
|
|
105
|
+
|
|
106
|
+
puts '=== Verification ==='
|
|
107
|
+
if new_task
|
|
108
|
+
puts <<~HEREDOC
|
|
109
|
+
Turn 1 returned input_required : PASS
|
|
110
|
+
Turn 2 skipped — server created a new task instead of resuming
|
|
111
|
+
(simple_a2a >= 0.3.1 required for full two-turn resume)
|
|
112
|
+
|
|
113
|
+
Turn 1 passed. Upgrade simple_a2a to >= 0.3.1 for the full two-turn demo.
|
|
114
|
+
HEREDOC
|
|
115
|
+
else
|
|
116
|
+
turn1_ok = state1 == 'input_required'
|
|
117
|
+
turn2_ok = state2 == 'completed'
|
|
118
|
+
reply_ok = reply&.include?('Alice')
|
|
119
|
+
choices_ok = question&.include?('1. Alice')
|
|
120
|
+
default_ok = question&.include?('Default: Friend')
|
|
121
|
+
|
|
122
|
+
puts <<~HEREDOC
|
|
123
|
+
Turn 1 returned input_required : #{turn1_ok ? 'PASS' : 'FAIL'}
|
|
124
|
+
Prompt contains numbered choices : #{choices_ok ? 'PASS' : 'FAIL'}
|
|
125
|
+
Prompt shows default value : #{default_ok ? 'PASS' : 'FAIL'}
|
|
126
|
+
Turn 2 completed after answer : #{turn2_ok ? 'PASS' : 'FAIL'}
|
|
127
|
+
Reply mentions the caller name : #{reply_ok ? 'PASS' : 'FAIL'}
|
|
128
|
+
|
|
129
|
+
HEREDOC
|
|
130
|
+
|
|
131
|
+
all_ok = turn1_ok && turn2_ok && reply_ok && choices_ok && default_ok
|
|
132
|
+
puts(all_ok ? 'All assertions passed.' : 'One or more assertions failed.')
|
|
133
|
+
end
|
|
134
|
+
rescue A2A::Error => e
|
|
135
|
+
puts <<~HEREDOC
|
|
136
|
+
A2A error on Turn 2: #{e.message}
|
|
137
|
+
|
|
138
|
+
=== Verification ===
|
|
139
|
+
Turn 1 returned input_required : #{state1 == 'input_required' ? 'PASS' : 'FAIL'}
|
|
140
|
+
Turn 2 skipped (server does not yet support resume via task_id)
|
|
141
|
+
|
|
142
|
+
Turn 1 passed. Upgrade simple_a2a to >= 0.3.1 for full two-turn demo.
|
|
143
|
+
HEREDOC
|
|
144
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/02_interactive_a2a_tool/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates :a2a_tool interactive mode.
|
|
7
|
+
#
|
|
8
|
+
# RobotLab::A2A::RobotAdapter injects an AskUserTool into the robot's local_tools
|
|
9
|
+
# before calling robot.run(). When the robot calls that tool, the adapter suspends
|
|
10
|
+
# the robot thread and returns an input_required task to the caller. A subsequent
|
|
11
|
+
# resume call (from an A2A client that supports task resume) unblocks the thread
|
|
12
|
+
# and allows the robot to finish.
|
|
13
|
+
#
|
|
14
|
+
# This demo shows the full two-turn flow: the robot enters input_required when
|
|
15
|
+
# the injected tool is called; the client resumes using the same task ID; the
|
|
16
|
+
# robot unblocks and completes the task (requires simple_a2a >= 0.3.1).
|
|
17
|
+
#
|
|
18
|
+
# AskUserTool is invoked with optional choices: and default: params. These are
|
|
19
|
+
# formatted into the input_required status message as a numbered choice list
|
|
20
|
+
# and a default-value hint, which the client receives and can display.
|
|
21
|
+
#
|
|
22
|
+
# To use a real LLM robot instead:
|
|
23
|
+
# require "robot_lab"
|
|
24
|
+
# robot = RobotLab.build(name: "greeter", system_prompt: "You are a friendly greeter...")
|
|
25
|
+
# # Add RobotLab::AskUser to the robot's tools so the LLM can call it.
|
|
26
|
+
# server = RobotLab::A2A::Server.new(interactive: :a2a_tool)
|
|
27
|
+
# server.add_robot(robot)
|
|
28
|
+
|
|
29
|
+
require_relative '../common_config'
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Robot — calls the injected AskUserTool to pause and ask the caller a question.
|
|
33
|
+
#
|
|
34
|
+
# In a real RobotLab robot the LLM decides when to call AskUser. Here we call it
|
|
35
|
+
# directly to keep the demo self-contained.
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
class PersonalGreeterRobot
|
|
38
|
+
Result = Struct.new(:reply)
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@local_tools = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def name = 'personal_greeter'
|
|
45
|
+
def description = 'Asks your name before composing a personalised greeting'
|
|
46
|
+
attr_reader :local_tools
|
|
47
|
+
|
|
48
|
+
def run(topic)
|
|
49
|
+
ask_tool = @local_tools.find { |t| t.is_a?(RobotLab::A2A::AskUserTool) }
|
|
50
|
+
|
|
51
|
+
name = if ask_tool
|
|
52
|
+
ask_tool.execute(
|
|
53
|
+
question: 'What is your name?',
|
|
54
|
+
choices: %w[Alice Bob Charlie],
|
|
55
|
+
default: 'Friend'
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
'stranger'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Result.new("Hello, #{name}! You wanted to know about: #{topic}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Server — :a2a_tool mode injects AskUserTool before each robot.run() call.
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
server = RobotLab::A2A::Server.new(host: 'localhost', port: 9292, interactive: :a2a_tool)
|
|
69
|
+
server.add_robot(PersonalGreeterRobot.new)
|
|
70
|
+
|
|
71
|
+
puts <<~HEREDOC
|
|
72
|
+
Starting PersonalGreeterRobot on http://localhost:9292/personal-greeter
|
|
73
|
+
Interactive mode: :a2a_tool (AskUserTool injection)
|
|
74
|
+
Press Ctrl-C to stop.
|
|
75
|
+
|
|
76
|
+
HEREDOC
|
|
77
|
+
|
|
78
|
+
server.run
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/03_robot_network/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/03_robot_network/server.rb
|
|
8
|
+
#
|
|
9
|
+
# Or run both together:
|
|
10
|
+
# bundle exec ruby examples/run 03_robot_network
|
|
11
|
+
#
|
|
12
|
+
# What this demo shows:
|
|
13
|
+
#
|
|
14
|
+
# A RobotLab::Network (a pipeline of cooperating robots) exposed as a single
|
|
15
|
+
# A2A agent via NetworkAdapter. The client sends raw text and receives output
|
|
16
|
+
# that has passed through all three pipeline stages in sequence. From the A2A
|
|
17
|
+
# protocol's point of view this is one agent with one task — the internal
|
|
18
|
+
# multi-robot structure is completely invisible across the HTTP boundary.
|
|
19
|
+
|
|
20
|
+
require_relative '../common_config'
|
|
21
|
+
|
|
22
|
+
URL = 'http://localhost:9292/editorial-pipeline'
|
|
23
|
+
|
|
24
|
+
client = A2A.client(url: URL)
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# 1. Discover the agent
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
puts '=== Agent Card ==='
|
|
30
|
+
card = client.agent_card
|
|
31
|
+
puts <<~HEREDOC
|
|
32
|
+
Name: #{card.name}
|
|
33
|
+
Description: #{card.description}
|
|
34
|
+
Skills: #{card.skills.map(&:name).join(', ')}
|
|
35
|
+
|
|
36
|
+
HEREDOC
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# 2. Send documents through the pipeline
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
documents = [
|
|
42
|
+
'the quick brown fox jumps over the lazy dog',
|
|
43
|
+
'ruby is a dynamic object-oriented programming language',
|
|
44
|
+
'the agent2agent protocol enables robots to communicate over http'
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
puts '=== Pipeline Results ==='
|
|
48
|
+
results = documents.map do |doc|
|
|
49
|
+
task = client.send_task(message: A2A::Models::Message.user(doc))
|
|
50
|
+
output = task.artifacts.first&.parts&.first&.text || '(no output)'
|
|
51
|
+
|
|
52
|
+
puts <<~HEREDOC
|
|
53
|
+
Input: #{doc.inspect}
|
|
54
|
+
Output: #{output.inspect}
|
|
55
|
+
State: #{task.status.state}
|
|
56
|
+
|
|
57
|
+
HEREDOC
|
|
58
|
+
{ task: task, output: output }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# 3. Verify all pipeline stages ran
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
puts '=== Verification ==='
|
|
65
|
+
checks = results.map do |r|
|
|
66
|
+
output = r[:output]
|
|
67
|
+
completed = r[:task].status.state == 'completed'
|
|
68
|
+
analysed = output.match?(/\[analysed:/i)
|
|
69
|
+
formatted = output.match?(/[A-Z]/)
|
|
70
|
+
summarised = output.start_with?('SUMMARY')
|
|
71
|
+
|
|
72
|
+
puts <<~HEREDOC
|
|
73
|
+
completed : #{completed ? 'PASS' : 'FAIL'}
|
|
74
|
+
analysed : #{analysed ? 'PASS' : 'FAIL'}
|
|
75
|
+
formatted : #{formatted ? 'PASS' : 'FAIL'}
|
|
76
|
+
summarised : #{summarised ? 'PASS' : 'FAIL'}
|
|
77
|
+
|
|
78
|
+
HEREDOC
|
|
79
|
+
|
|
80
|
+
completed && analysed && formatted && summarised
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts(checks.all? ? 'All pipeline checks passed.' : 'One or more checks failed.')
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/03_robot_network/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Wraps a RobotLab-style network (a pipeline of robots) as a single A2A agent.
|
|
7
|
+
# The network processes the input through multiple stages in sequence and returns
|
|
8
|
+
# the final output. From the A2A client's perspective it looks like one agent.
|
|
9
|
+
#
|
|
10
|
+
# NetworkAdapter only supports :none (synchronous) mode. For interactive networks
|
|
11
|
+
# wrap individual robots with RobotAdapter instead.
|
|
12
|
+
#
|
|
13
|
+
# To use a real RobotLab::Network:
|
|
14
|
+
# require "robot_lab"
|
|
15
|
+
# network = RobotLab.create_network(name: "pipeline") do
|
|
16
|
+
# step :analyst, analyst_robot, depends_on: :none
|
|
17
|
+
# step :writer, writer_robot, depends_on: [:analyst]
|
|
18
|
+
# end
|
|
19
|
+
# server = RobotLab::A2A::Server.new
|
|
20
|
+
# server.add_network(network, name: "pipeline")
|
|
21
|
+
|
|
22
|
+
require_relative '../common_config'
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Individual robots — each transforms the text in a distinct way.
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
class AnalyserRobot
|
|
28
|
+
Result = Struct.new(:reply)
|
|
29
|
+
def run(input) = Result.new("[analysed: #{input.split.length} words] #{input}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class FormatterRobot
|
|
33
|
+
Result = Struct.new(:reply)
|
|
34
|
+
def run(input) = Result.new(input.gsub(/\b\w/, &:upcase))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class SummaryRobot
|
|
38
|
+
Result = Struct.new(:reply)
|
|
39
|
+
def run(input) = Result.new("SUMMARY → #{input}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Pipeline network — runs robots in sequence, passing output as the next input.
|
|
44
|
+
#
|
|
45
|
+
# Implements the interface expected by NetworkAdapter:
|
|
46
|
+
# network.run(message: String) → object responding to #last_text_content
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
class EditorialPipeline
|
|
49
|
+
NetworkResult = Struct.new(:last_text_content)
|
|
50
|
+
|
|
51
|
+
STAGES = [AnalyserRobot, FormatterRobot, SummaryRobot].freeze
|
|
52
|
+
|
|
53
|
+
def run(message:)
|
|
54
|
+
output = STAGES.reduce(message) { |text, klass| klass.new.run(text).reply }
|
|
55
|
+
NetworkResult.new(output)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Server — add_network requires an explicit name: for the agent card.
|
|
61
|
+
# The path defaults to "/editorial-pipeline" (DNS-safe label of the name).
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
server = RobotLab::A2A::Server.new(host: 'localhost', port: 9292)
|
|
64
|
+
server.add_network(
|
|
65
|
+
EditorialPipeline.new,
|
|
66
|
+
name: 'editorial_pipeline',
|
|
67
|
+
description: 'Three-stage text pipeline: analyse → format → summarise'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
puts <<~HEREDOC
|
|
71
|
+
Starting EditorialPipeline on http://localhost:9292/editorial-pipeline
|
|
72
|
+
Stages: AnalyserRobot → FormatterRobot → SummaryRobot
|
|
73
|
+
Press Ctrl-C to stop.
|
|
74
|
+
|
|
75
|
+
HEREDOC
|
|
76
|
+
|
|
77
|
+
server.run
|