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,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