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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/04_io_bridge/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/04_io_bridge/server.rb
|
|
8
|
+
#
|
|
9
|
+
# Or run both together:
|
|
10
|
+
# bundle exec ruby examples/run 04_io_bridge
|
|
11
|
+
#
|
|
12
|
+
# What this demo shows:
|
|
13
|
+
#
|
|
14
|
+
# Turn 1 — client sends a topic ('stoicism').
|
|
15
|
+
# QuoteRobot writes the prompt to @output (buffered by IoBridge)
|
|
16
|
+
# then calls @input.gets. IoBridge flushes the buffer as the
|
|
17
|
+
# input_required message and blocks. The A2A task enters the
|
|
18
|
+
# input_required state with the prompt as its status message.
|
|
19
|
+
#
|
|
20
|
+
# Turn 2 — client sends a follow-up with task_id: pointing at the same task.
|
|
21
|
+
# IoBridge pushes the answer to the robot thread, unblocking gets.
|
|
22
|
+
# The robot completes and the task reaches the "completed" state.
|
|
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/quote-robot'
|
|
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: 'stoicism'
|
|
57
|
+
|
|
58
|
+
HEREDOC
|
|
59
|
+
|
|
60
|
+
task1 = client.send_task(message: A2A::Models::Message.user('stoicism'))
|
|
61
|
+
|
|
62
|
+
state1 = task1.status.state
|
|
63
|
+
prompt = task1.status.message&.parts&.first&.text
|
|
64
|
+
|
|
65
|
+
puts " Task ID: #{task1.id}"
|
|
66
|
+
puts " State: #{state1}"
|
|
67
|
+
puts " Prompt: #{prompt}" if prompt
|
|
68
|
+
puts
|
|
69
|
+
|
|
70
|
+
if state1 != 'input_required'
|
|
71
|
+
puts "Expected input_required — got #{state1.inspect}."
|
|
72
|
+
puts 'Check that the server is running with interactive: :io_bridge.'
|
|
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: 'Ada'
|
|
85
|
+
|
|
86
|
+
HEREDOC
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
task2 = client.send_task(
|
|
90
|
+
message: A2A::Models::Message.user('Ada'),
|
|
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
|
|
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?('Ada')
|
|
119
|
+
|
|
120
|
+
puts <<~HEREDOC
|
|
121
|
+
Turn 1 returned input_required : #{turn1_ok ? 'PASS' : 'FAIL'}
|
|
122
|
+
Turn 2 completed after answer : #{turn2_ok ? 'PASS' : 'FAIL'}
|
|
123
|
+
Reply addresses the caller : #{reply_ok ? 'PASS' : 'FAIL'}
|
|
124
|
+
|
|
125
|
+
HEREDOC
|
|
126
|
+
|
|
127
|
+
all_ok = turn1_ok && turn2_ok && reply_ok
|
|
128
|
+
puts(all_ok ? 'All assertions passed.' : 'One or more assertions failed.')
|
|
129
|
+
end
|
|
130
|
+
rescue A2A::Error => e
|
|
131
|
+
puts <<~HEREDOC
|
|
132
|
+
A2A error on Turn 2: #{e.message}
|
|
133
|
+
|
|
134
|
+
=== Verification ===
|
|
135
|
+
Turn 1 returned input_required : #{state1 == 'input_required' ? 'PASS' : 'FAIL'}
|
|
136
|
+
Turn 2 skipped (server does not yet support resume via task_id)
|
|
137
|
+
|
|
138
|
+
Turn 1 passed. Upgrade simple_a2a to >= 0.3.1 for the full two-turn demo.
|
|
139
|
+
HEREDOC
|
|
140
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/04_io_bridge/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates :io_bridge interactive mode.
|
|
7
|
+
#
|
|
8
|
+
# IoBridge replaces robot.input and robot.output before each robot.run()
|
|
9
|
+
# call. Text written to @output is buffered; when the robot calls @input.gets,
|
|
10
|
+
# the buffer is emitted as an input_required A2A message and the call blocks
|
|
11
|
+
# until the client resumes the task. The robot thread then unblocks, receiving
|
|
12
|
+
# the answer as if a user had typed it at a terminal.
|
|
13
|
+
#
|
|
14
|
+
# Key point: QuoteRobot has NO knowledge of A2A. It reads and writes plain Ruby
|
|
15
|
+
# IO — the same code runs interactively in a terminal:
|
|
16
|
+
#
|
|
17
|
+
# robot = QuoteRobot.new
|
|
18
|
+
# robot.run('stoicism') # prompts and reads from $stdin/$stdout
|
|
19
|
+
#
|
|
20
|
+
# Contrast with :a2a_tool, which requires the robot to call an injected tool.
|
|
21
|
+
# Use :io_bridge for existing robots that already use puts/gets directly.
|
|
22
|
+
|
|
23
|
+
require_relative '../common_config'
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Robot — standard Ruby IO; no mention of A2A, tools, or queues.
|
|
27
|
+
# attr_writer :input, :output lets RobotAdapter inject the IoBridge before run.
|
|
28
|
+
# Without injection, run() falls back to $stdin/$stdout (terminal mode).
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
class QuoteRobot
|
|
31
|
+
Result = Struct.new(:reply)
|
|
32
|
+
|
|
33
|
+
def name = 'quote_robot'
|
|
34
|
+
def description = 'Composes an inspirational quote addressed to the caller'
|
|
35
|
+
|
|
36
|
+
attr_writer :input, :output
|
|
37
|
+
|
|
38
|
+
def run(topic)
|
|
39
|
+
out = @output || $stdout
|
|
40
|
+
inp = @input || $stdin
|
|
41
|
+
|
|
42
|
+
out.puts "Who should I address this #{topic} quote to?"
|
|
43
|
+
name = inp.gets.chomp
|
|
44
|
+
|
|
45
|
+
Result.new(
|
|
46
|
+
"#{name}: \"#{topic.capitalize} is not a destination — it is a way of travelling.\""
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Server — :io_bridge injects IoBridge as robot.input and robot.output.
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
server = RobotLab::A2A::Server.new(host: 'localhost', port: 9292, interactive: :io_bridge)
|
|
55
|
+
server.add_robot(QuoteRobot.new)
|
|
56
|
+
|
|
57
|
+
puts <<~HEREDOC
|
|
58
|
+
Starting QuoteRobot on http://localhost:9292/quote-robot
|
|
59
|
+
Interactive mode: :io_bridge (plain IO replaced by IoBridge)
|
|
60
|
+
Press Ctrl-C to stop.
|
|
61
|
+
|
|
62
|
+
HEREDOC
|
|
63
|
+
|
|
64
|
+
server.run
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/05_multi_agent/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/05_multi_agent/server.rb
|
|
8
|
+
#
|
|
9
|
+
# Or run both together:
|
|
10
|
+
# bundle exec ruby examples/run 05_multi_agent
|
|
11
|
+
#
|
|
12
|
+
# What this demo shows:
|
|
13
|
+
#
|
|
14
|
+
# Two independent A2A clients, each pointing at a different agent path on
|
|
15
|
+
# the same server. Each agent has its own agent card and processes the same
|
|
16
|
+
# input text in a completely different way. From the A2A protocol's point of
|
|
17
|
+
# view they are separate agents — the shared server process is invisible.
|
|
18
|
+
|
|
19
|
+
require_relative '../common_config'
|
|
20
|
+
|
|
21
|
+
BASE_URL = 'http://localhost:9292'
|
|
22
|
+
HEADLINE_URL = "#{BASE_URL}/headline"
|
|
23
|
+
TAGS_URL = "#{BASE_URL}/tags"
|
|
24
|
+
|
|
25
|
+
headline_client = A2A.client(url: HEADLINE_URL)
|
|
26
|
+
tags_client = A2A.client(url: TAGS_URL)
|
|
27
|
+
|
|
28
|
+
def divider = puts('─' * 60)
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Discover both agents
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
puts
|
|
34
|
+
puts '=== Agent Cards ==='
|
|
35
|
+
headline_card = headline_client.agent_card
|
|
36
|
+
tags_card = tags_client.agent_card
|
|
37
|
+
|
|
38
|
+
puts <<~HEREDOC
|
|
39
|
+
/headline #{headline_card.name}: #{headline_card.description}
|
|
40
|
+
/tags #{tags_card.name}: #{tags_card.description}
|
|
41
|
+
|
|
42
|
+
HEREDOC
|
|
43
|
+
|
|
44
|
+
divider
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Send the same text to both agents
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
INPUT = 'the agent2agent protocol enables robots to collaborate over HTTP'
|
|
50
|
+
|
|
51
|
+
puts <<~HEREDOC
|
|
52
|
+
|
|
53
|
+
=== Sending to both agents ===
|
|
54
|
+
Input: #{INPUT.inspect}
|
|
55
|
+
|
|
56
|
+
HEREDOC
|
|
57
|
+
|
|
58
|
+
headline_task = headline_client.send_task(message: A2A::Models::Message.user(INPUT))
|
|
59
|
+
tags_task = tags_client.send_task(message: A2A::Models::Message.user(INPUT))
|
|
60
|
+
|
|
61
|
+
headline_reply = headline_task.artifacts.first&.parts&.first&.text || '(no reply)'
|
|
62
|
+
tags_reply = tags_task.artifacts.first&.parts&.first&.text || '(no reply)'
|
|
63
|
+
|
|
64
|
+
puts <<~HEREDOC
|
|
65
|
+
HeadlineRobot [#{headline_task.status.state}]
|
|
66
|
+
#{headline_reply}
|
|
67
|
+
|
|
68
|
+
TagRobot [#{tags_task.status.state}]
|
|
69
|
+
#{tags_reply}
|
|
70
|
+
|
|
71
|
+
HEREDOC
|
|
72
|
+
|
|
73
|
+
divider
|
|
74
|
+
puts
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Verify
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
puts '=== Verification ==='
|
|
80
|
+
|
|
81
|
+
headline_ok = headline_task.status.state == 'completed'
|
|
82
|
+
tags_ok = tags_task.status.state == 'completed'
|
|
83
|
+
distinct_ok = headline_reply != tags_reply
|
|
84
|
+
headline_cap = headline_reply.match?(/\A[A-Z]/)
|
|
85
|
+
tags_hash = tags_reply.include?('#')
|
|
86
|
+
|
|
87
|
+
puts <<~HEREDOC
|
|
88
|
+
HeadlineRobot completed : #{headline_ok ? 'PASS' : 'FAIL'}
|
|
89
|
+
TagRobot completed : #{tags_ok ? 'PASS' : 'FAIL'}
|
|
90
|
+
Responses are distinct : #{distinct_ok ? 'PASS' : 'FAIL'}
|
|
91
|
+
Headline starts capitalised: #{headline_cap ? 'PASS' : 'FAIL'}
|
|
92
|
+
Tags contain hash symbols : #{tags_hash ? 'PASS' : 'FAIL'}
|
|
93
|
+
|
|
94
|
+
HEREDOC
|
|
95
|
+
|
|
96
|
+
all_ok = headline_ok && tags_ok && distinct_ok && headline_cap && tags_hash
|
|
97
|
+
puts(all_ok ? 'All assertions passed.' : 'One or more assertions failed.')
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/05_multi_agent/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates multiple A2A agents on a single server using the fluent
|
|
7
|
+
# builder API with explicit path overrides.
|
|
8
|
+
#
|
|
9
|
+
# Each robot is mounted at its own URL path and exposed as an independent
|
|
10
|
+
# A2A agent with its own agent card. An A2A client discovers and addresses
|
|
11
|
+
# each agent separately — they share the same server process but are
|
|
12
|
+
# completely independent from the protocol's point of view.
|
|
13
|
+
#
|
|
14
|
+
# Server builder pattern:
|
|
15
|
+
# server.add_robot(robot_a, path: '/headline')
|
|
16
|
+
# .add_robot(robot_b, path: '/tags')
|
|
17
|
+
# .run
|
|
18
|
+
|
|
19
|
+
require_relative '../common_config'
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# HeadlineRobot — turns a passage of text into a punchy headline.
|
|
23
|
+
# Registered at /headline via an explicit path: override.
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
class HeadlineRobot
|
|
26
|
+
Result = Struct.new(:reply)
|
|
27
|
+
|
|
28
|
+
def name = 'headline_robot'
|
|
29
|
+
def description = 'Condenses input text into a short, punchy headline'
|
|
30
|
+
|
|
31
|
+
def run(input)
|
|
32
|
+
words = input.strip.split
|
|
33
|
+
headline = words.first(6).map(&:capitalize).join(' ')
|
|
34
|
+
headline += '…' if words.length > 6
|
|
35
|
+
Result.new(headline)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# TagRobot — extracts hashtag-style keywords from a passage of text.
|
|
41
|
+
# Registered at /tags via an explicit path: override.
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
class TagRobot
|
|
44
|
+
Result = Struct.new(:reply)
|
|
45
|
+
|
|
46
|
+
def name = 'tag_robot'
|
|
47
|
+
def description = 'Extracts hashtag keywords from input text'
|
|
48
|
+
|
|
49
|
+
def run(input)
|
|
50
|
+
tags = input.downcase
|
|
51
|
+
.scan(/\b[a-z]{4,}\b/)
|
|
52
|
+
.uniq
|
|
53
|
+
.first(5)
|
|
54
|
+
.map { |w| "##{w}" }
|
|
55
|
+
.join(' ')
|
|
56
|
+
Result.new(tags)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Server — two robots, two paths, one server process.
|
|
62
|
+
# The fluent add_robot calls return self so they can be chained.
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
server = RobotLab::A2A::Server.new(host: 'localhost', port: 9292)
|
|
65
|
+
server.add_robot(HeadlineRobot.new, path: '/headline')
|
|
66
|
+
.add_robot(TagRobot.new, path: '/tags')
|
|
67
|
+
|
|
68
|
+
puts <<~HEREDOC
|
|
69
|
+
Starting multi-agent server on http://localhost:9292
|
|
70
|
+
/headline — HeadlineRobot
|
|
71
|
+
/tags — TagRobot
|
|
72
|
+
Press Ctrl-C to stop.
|
|
73
|
+
|
|
74
|
+
HEREDOC
|
|
75
|
+
|
|
76
|
+
server.run
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/06_rack_mount/client.rb
|
|
5
|
+
#
|
|
6
|
+
# Start the server first:
|
|
7
|
+
# bundle exec ruby examples/06_rack_mount/server.rb
|
|
8
|
+
#
|
|
9
|
+
# Or run both together:
|
|
10
|
+
# bundle exec ruby examples/run 06_rack_mount
|
|
11
|
+
#
|
|
12
|
+
# What this demo shows:
|
|
13
|
+
#
|
|
14
|
+
# The A2A agent and a plain Rack endpoint live on the same server process.
|
|
15
|
+
# The client exercises both, proving that non-A2A routes and A2A agent routes
|
|
16
|
+
# coexist correctly when the agent is mounted via server.to_app.
|
|
17
|
+
|
|
18
|
+
require_relative '../common_config'
|
|
19
|
+
require 'net/http'
|
|
20
|
+
require 'json'
|
|
21
|
+
|
|
22
|
+
BASE_URL = 'http://localhost:9292'
|
|
23
|
+
AGENT_URL = "#{BASE_URL}/echo-robot"
|
|
24
|
+
|
|
25
|
+
a2a_client = A2A.client(url: AGENT_URL)
|
|
26
|
+
|
|
27
|
+
def divider = puts('─' * 60)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# 1. Health check — plain HTTP GET to the non-A2A Rack endpoint
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
puts
|
|
33
|
+
puts '=== Health Check (plain Rack endpoint) ==='
|
|
34
|
+
|
|
35
|
+
health_response = Net::HTTP.get_response(URI("#{BASE_URL}/health"))
|
|
36
|
+
health_body = JSON.parse(health_response.body) rescue {}
|
|
37
|
+
|
|
38
|
+
puts <<~HEREDOC
|
|
39
|
+
Status code : #{health_response.code}
|
|
40
|
+
Body : #{health_response.body}
|
|
41
|
+
|
|
42
|
+
HEREDOC
|
|
43
|
+
|
|
44
|
+
divider
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# 2. A2A agent — standard task send/receive
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
puts
|
|
50
|
+
puts '=== Agent Card ==='
|
|
51
|
+
card = a2a_client.agent_card
|
|
52
|
+
puts <<~HEREDOC
|
|
53
|
+
Name: #{card.name}
|
|
54
|
+
Description: #{card.description}
|
|
55
|
+
|
|
56
|
+
HEREDOC
|
|
57
|
+
|
|
58
|
+
puts '=== Send Task ==='
|
|
59
|
+
task = a2a_client.send_task(message: A2A::Models::Message.user('hello from rack mount'))
|
|
60
|
+
reply = task.artifacts.first&.parts&.first&.text || '(no reply)'
|
|
61
|
+
|
|
62
|
+
puts <<~HEREDOC
|
|
63
|
+
State : #{task.status.state}
|
|
64
|
+
Reply : #{reply}
|
|
65
|
+
|
|
66
|
+
HEREDOC
|
|
67
|
+
|
|
68
|
+
divider
|
|
69
|
+
puts
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Verify
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
puts '=== Verification ==='
|
|
75
|
+
|
|
76
|
+
health_ok = health_response.code == '200'
|
|
77
|
+
status_ok = health_body['status'] == 'ok'
|
|
78
|
+
task_ok = task.status.state == 'completed'
|
|
79
|
+
reply_ok = reply.include?('HELLO')
|
|
80
|
+
|
|
81
|
+
puts <<~HEREDOC
|
|
82
|
+
Health endpoint returned 200 : #{health_ok ? 'PASS' : 'FAIL'}
|
|
83
|
+
Health body status is ok : #{status_ok ? 'PASS' : 'FAIL'}
|
|
84
|
+
A2A task completed : #{task_ok ? 'PASS' : 'FAIL'}
|
|
85
|
+
A2A reply contains echo : #{reply_ok ? 'PASS' : 'FAIL'}
|
|
86
|
+
|
|
87
|
+
HEREDOC
|
|
88
|
+
|
|
89
|
+
all_ok = health_ok && status_ok && task_ok && reply_ok
|
|
90
|
+
puts(all_ok ? 'All assertions passed.' : 'One or more assertions failed.')
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# config.ru — standalone Rack / Rails mount reference
|
|
4
|
+
#
|
|
5
|
+
# This file shows how to embed a RobotLab A2A agent inside any Rack-compatible
|
|
6
|
+
# server using server.to_app. It is NOT used by the demo runner (which uses
|
|
7
|
+
# server.rb); it is here as a copy-paste reference.
|
|
8
|
+
#
|
|
9
|
+
# Run standalone with:
|
|
10
|
+
# bundle exec rackup examples/06_rack_mount/config.ru -p 9292
|
|
11
|
+
#
|
|
12
|
+
# Mount inside a Rails app (config/routes.rb):
|
|
13
|
+
# a2a = RobotLab::A2A::Server.new
|
|
14
|
+
# a2a.add_robot(MyRobot.new)
|
|
15
|
+
# mount a2a.to_app, at: '/'
|
|
16
|
+
#
|
|
17
|
+
# Mount inside a Sinatra app:
|
|
18
|
+
# use Rack::URLMap, '/' => RobotLab::A2A::Server.new.add_robot(MyRobot.new).to_app
|
|
19
|
+
|
|
20
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
21
|
+
require 'robot_lab/a2a'
|
|
22
|
+
require 'json'
|
|
23
|
+
|
|
24
|
+
class EchoRobot
|
|
25
|
+
Result = Struct.new(:reply)
|
|
26
|
+
def name = 'echo_robot'
|
|
27
|
+
def description = 'Echoes input back in uppercase with a character count'
|
|
28
|
+
def run(input)
|
|
29
|
+
text = input.strip
|
|
30
|
+
Result.new("#{text.upcase} [#{text.length} chars]")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
a2a = RobotLab::A2A::Server.new
|
|
35
|
+
a2a.add_robot(EchoRobot.new)
|
|
36
|
+
|
|
37
|
+
health = lambda do |_env|
|
|
38
|
+
[200, { 'content-type' => 'application/json' }, [{ status: 'ok' }.to_json]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
run Rack::URLMap.new(
|
|
42
|
+
'/health' => health,
|
|
43
|
+
'/' => a2a.to_app
|
|
44
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/06_rack_mount/server.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates server.to_app — embedding A2A agents inside a larger Rack app.
|
|
7
|
+
#
|
|
8
|
+
# server.run — starts a dedicated Falcon server for A2A agents only.
|
|
9
|
+
# server.to_app — returns a Rack::URLMap that can be composed with other
|
|
10
|
+
# Rack middleware and mounted in any Rack-compatible server
|
|
11
|
+
# (Rails, Sinatra, Puma, Falcon, etc.).
|
|
12
|
+
#
|
|
13
|
+
# This demo composes the A2A agent with a /health JSON endpoint, runs the
|
|
14
|
+
# combined Rack app using the same Falcon runner simple_a2a uses internally.
|
|
15
|
+
#
|
|
16
|
+
# In a Rails application the equivalent is:
|
|
17
|
+
#
|
|
18
|
+
# # config/routes.rb
|
|
19
|
+
# mount RobotLab::A2A::Server.new.add_robot(my_robot).to_app, at: '/'
|
|
20
|
+
#
|
|
21
|
+
# See config.ru in this directory for the standalone Rack equivalent.
|
|
22
|
+
|
|
23
|
+
require_relative '../common_config'
|
|
24
|
+
require 'json'
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Robot — simple sync robot, identical role to EchoRobot in 01_sync_robot.
|
|
28
|
+
# The interesting part here is how the server is wired up, not the robot.
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
class EchoRobot
|
|
31
|
+
Result = Struct.new(:reply)
|
|
32
|
+
|
|
33
|
+
def name = 'echo_robot'
|
|
34
|
+
def description = 'Echoes input back in uppercase with a character count'
|
|
35
|
+
|
|
36
|
+
def run(input)
|
|
37
|
+
text = input.strip
|
|
38
|
+
Result.new("#{text.upcase} [#{text.length} chars]")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Build the A2A Rack app via to_app.
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
a2a = RobotLab::A2A::Server.new(host: 'localhost', port: 9292)
|
|
46
|
+
a2a.add_robot(EchoRobot.new)
|
|
47
|
+
|
|
48
|
+
a2a_app = a2a.to_app # returns a Rack::URLMap — composable with any Rack app
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Compose with a /health endpoint to simulate a real multi-purpose app.
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
health = lambda do |_env|
|
|
54
|
+
body = { status: 'ok', agents: ['echo_robot'] }.to_json
|
|
55
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
combined_app = Rack::URLMap.new(
|
|
59
|
+
'/health' => health,
|
|
60
|
+
'/' => a2a_app
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
puts <<~HEREDOC
|
|
64
|
+
Starting combined Rack app on http://localhost:9292
|
|
65
|
+
/health — JSON health check (plain Rack lambda)
|
|
66
|
+
/echo-robot — A2A EchoRobot (via server.to_app)
|
|
67
|
+
Press Ctrl-C to stop.
|
|
68
|
+
|
|
69
|
+
HEREDOC
|
|
70
|
+
|
|
71
|
+
# Run the composed Rack app with the same Falcon runner simple_a2a uses.
|
|
72
|
+
A2A::Server::FalconRunner.new(combined_app, host: 'localhost', port: 9292).run
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# robot_lab-a2a/examples/common_config.rb
|
|
4
|
+
#
|
|
5
|
+
# Loaded by every example. Puts the gem's lib/ on the path so examples run
|
|
6
|
+
# from a checkout without a gem install.
|
|
7
|
+
|
|
8
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
9
|
+
require 'robot_lab/a2a'
|