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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.rubocop.yml +26 -0
  5. data/CHANGELOG.md +24 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +104 -0
  10. data/docs/assets/images/architecture.png +0 -0
  11. data/docs/assets/images/architecture.svg +258 -0
  12. data/docs/examples.md +116 -0
  13. data/docs/getting-started.md +103 -0
  14. data/docs/index.md +23 -0
  15. data/docs/interactive-modes.md +104 -0
  16. data/docs/server-api.md +118 -0
  17. data/examples/01_sync_robot/client.rb +94 -0
  18. data/examples/01_sync_robot/server.rb +45 -0
  19. data/examples/02_interactive_a2a_tool/client.rb +144 -0
  20. data/examples/02_interactive_a2a_tool/server.rb +78 -0
  21. data/examples/03_robot_network/client.rb +83 -0
  22. data/examples/03_robot_network/server.rb +77 -0
  23. data/examples/04_io_bridge/client.rb +140 -0
  24. data/examples/04_io_bridge/server.rb +64 -0
  25. data/examples/05_multi_agent/client.rb +97 -0
  26. data/examples/05_multi_agent/server.rb +76 -0
  27. data/examples/06_rack_mount/client.rb +90 -0
  28. data/examples/06_rack_mount/config.ru +44 -0
  29. data/examples/06_rack_mount/server.rb +72 -0
  30. data/examples/common_config.rb +9 -0
  31. data/examples/run +112 -0
  32. data/lib/robot_lab/a2a/ask_user_tool.rb +43 -0
  33. data/lib/robot_lab/a2a/io_bridge.rb +75 -0
  34. data/lib/robot_lab/a2a/network_adapter.rb +38 -0
  35. data/lib/robot_lab/a2a/registry.rb +36 -0
  36. data/lib/robot_lab/a2a/robot_adapter.rb +183 -0
  37. data/lib/robot_lab/a2a/server.rb +128 -0
  38. data/lib/robot_lab/a2a/version.rb +7 -0
  39. data/lib/robot_lab/a2a.rb +39 -0
  40. data/mkdocs.yml +153 -0
  41. 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'