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
data/examples/run ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run a demo end-to-end: starts the server, waits until it accepts
5
+ # connections, runs the client, then shuts the server down.
6
+ #
7
+ # Usage (from the project root):
8
+ # bundle exec ruby examples/run 01_sync_robot
9
+ #
10
+ # Usage (from inside examples/):
11
+ # ./run 01_sync_robot
12
+
13
+ require 'rubygems'
14
+ require 'socket'
15
+
16
+ EXAMPLES_DIR = File.expand_path(__dir__)
17
+ RUBY = RbConfig.ruby
18
+ BUNDLE = Gem.bin_path('bundler', 'bundle')
19
+ SERVER_PORT = 9292
20
+ STARTUP_TIMEOUT = 15 # seconds
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Argument handling
24
+ # ---------------------------------------------------------------------------
25
+ name = ARGV.first&.chomp('/')
26
+
27
+ unless name
28
+ puts 'Usage: run <demo-name>'
29
+ puts
30
+ puts 'Available demos:'
31
+ Dir["#{EXAMPLES_DIR}/*/server.rb"].each do |f|
32
+ puts " #{File.basename(File.dirname(f))}"
33
+ end
34
+ exit 1
35
+ end
36
+
37
+ demo_dir = File.join(EXAMPLES_DIR, name)
38
+
39
+ abort "Demo not found: #{demo_dir}" unless File.directory?(demo_dir)
40
+
41
+ # If the demo has its own run script, delegate to it.
42
+ custom_runner = File.join(demo_dir, 'run')
43
+ exec(RUBY, custom_runner, *ARGV.drop(1)) if File.exist?(custom_runner)
44
+
45
+ server_rb = File.join(demo_dir, 'server.rb')
46
+ client_rb = File.join(demo_dir, 'client.rb')
47
+
48
+ abort "Missing server.rb in #{demo_dir}" unless File.exist?(server_rb)
49
+ abort "Missing client.rb in #{demo_dir}" unless File.exist?(client_rb)
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Helpers
53
+ # ---------------------------------------------------------------------------
54
+ def banner(text)
55
+ bar = '─' * (text.length + 4)
56
+ puts "┌#{bar}┐"
57
+ puts "│ #{text} │"
58
+ puts "└#{bar}┘"
59
+ end
60
+
61
+ def server_ready?(port)
62
+ TCPSocket.new('localhost', port).close
63
+ true
64
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT
65
+ false
66
+ end
67
+
68
+ def wait_for_server(port, timeout)
69
+ deadline = Time.now + timeout
70
+ sleep 0.05 until server_ready?(port) || Time.now > deadline
71
+ server_ready?(port)
72
+ end
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Start server
76
+ # ---------------------------------------------------------------------------
77
+ banner "Starting #{name} — server"
78
+ server_pid = spawn(BUNDLE, 'exec', RUBY, server_rb, out: $stdout, err: $stderr)
79
+
80
+ unless wait_for_server(SERVER_PORT, STARTUP_TIMEOUT)
81
+ begin
82
+ Process.kill('TERM', server_pid)
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ abort "\nServer did not become ready within #{STARTUP_TIMEOUT}s — aborting."
87
+ end
88
+
89
+ puts "\n(server ready on port #{SERVER_PORT})\n\n"
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Run client
93
+ # ---------------------------------------------------------------------------
94
+ banner "Running #{name} — client"
95
+ begin
96
+ client_ok = system(BUNDLE, 'exec', RUBY, client_rb)
97
+ rescue Interrupt
98
+ client_ok = false
99
+ end
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Shut down server
103
+ # ---------------------------------------------------------------------------
104
+ puts "\n(stopping server…)"
105
+ begin
106
+ Process.kill('TERM', server_pid)
107
+ Process.wait(server_pid)
108
+ rescue Errno::ESRCH, Errno::ECHILD
109
+ # already gone
110
+ end
111
+
112
+ exit(client_ok ? 0 : 1)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ # Drop-in replacement for RobotLab::AskUser when a robot runs under A2A.
6
+ #
7
+ # Instead of blocking a terminal, it pushes an input_required event through
8
+ # the event_queue so the RobotAdapter can call task.require_input! and
9
+ # emit_status(final: true), then blocks on answer_queue until the A2A client
10
+ # resumes the task with another message.
11
+ #
12
+ # Inject before robot.run() via RobotAdapter — do not use directly.
13
+ class AskUserTool < RobotLab::Tool
14
+ description 'Ask the user a clarifying question and wait for their response'
15
+ param :question, type: 'string', desc: 'The question to present to the user'
16
+ param :choices, type: 'array', desc: 'Optional list of choices to present', required: false
17
+ param :default, type: 'string', desc: 'Default value if user presses Enter', required: false
18
+
19
+ attr_writer :event_queue, :answer_queue
20
+
21
+ def execute(question:, choices: nil, default: nil)
22
+ raise 'A2A queues not injected — use RobotLab::A2A::RobotAdapter' unless @event_queue
23
+
24
+ prompt_text = format_prompt(question, choices, default)
25
+ prompt = ::A2A::Models::Message.agent(prompt_text)
26
+ @event_queue.push({ type: :ask, prompt: prompt })
27
+
28
+ answer = @answer_queue.pop
29
+ answer = default if (answer.nil? || answer.strip.empty?) && default
30
+ answer.to_s
31
+ end
32
+
33
+ private
34
+
35
+ def format_prompt(question, choices, default)
36
+ lines = [question]
37
+ choices.each_with_index { |c, i| lines << " #{i + 1}. #{c}" } if choices.is_a?(Array) && choices.any?
38
+ lines << "(Default: #{default})" if default
39
+ lines.join("\n")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ # IO-compatible wrapper for :io_bridge interactive mode.
6
+ #
7
+ # Inject as both robot.input and robot.output before robot.run().
8
+ # Output written by AskUser (the prompt text) is buffered; when AskUser
9
+ # calls gets(), the buffer becomes the input_required message and the call
10
+ # blocks until the A2A client resumes the task with an answer.
11
+ #
12
+ # This lets existing robots that use RobotLab::AskUser work under A2A
13
+ # without any modification — the terminal is replaced by the HTTP protocol.
14
+ #
15
+ # Only works with in-process storage: the robot thread must stay alive
16
+ # between INPUT_REQUIRED and resume.
17
+ class IoBridge
18
+ def initialize(event_queue:, answer_queue:)
19
+ @event_queue = event_queue
20
+ @answer_queue = answer_queue
21
+ @output_buffer = +''
22
+ @buffer_mutex = Mutex.new
23
+ end
24
+
25
+ # --- output side (robot.output) ---
26
+
27
+ def print(*args)
28
+ @buffer_mutex.synchronize { @output_buffer << args.join }
29
+ nil
30
+ end
31
+
32
+ def puts(*args)
33
+ @buffer_mutex.synchronize do
34
+ if args.empty?
35
+ @output_buffer << "\n"
36
+ else
37
+ args.each { |a| @output_buffer << a.to_s << "\n" }
38
+ end
39
+ end
40
+ nil
41
+ end
42
+
43
+ def write(str)
44
+ @buffer_mutex.synchronize { @output_buffer << str.to_s }
45
+ str.to_s.length
46
+ end
47
+
48
+ def flush
49
+ self
50
+ end
51
+
52
+ def sync=(*)
53
+ # no-op — satisfy IO compatibility
54
+ end
55
+
56
+ # --- input side (robot.input) ---
57
+
58
+ def gets
59
+ prompt_text = @buffer_mutex.synchronize do
60
+ text = @output_buffer.dup.strip
61
+ @output_buffer.clear
62
+ text
63
+ end
64
+
65
+ prompt = ::A2A::Models::Message.agent(
66
+ prompt_text.empty? ? 'Input required:' : prompt_text
67
+ )
68
+ @event_queue.push({ type: :ask, prompt: prompt })
69
+
70
+ answer = @answer_queue.pop
71
+ "#{answer}\n"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ # Wraps a RobotLab::Network as an A2A AgentExecutor.
6
+ #
7
+ # Only :none interactive mode is supported — interactive flows require
8
+ # per-robot queue injection across all network robots, planned for a future
9
+ # release. Wrap individual robots with RobotAdapter for interactive flows.
10
+ class NetworkAdapter < ::A2A::Server::AgentExecutor
11
+ def initialize(network, interactive: :none)
12
+ super()
13
+ if interactive != :none
14
+ raise ArgumentError,
15
+ 'NetworkAdapter only supports interactive: :none. ' \
16
+ 'For interactive network flows, wrap individual robots with RobotAdapter.'
17
+ end
18
+
19
+ @network = network
20
+ end
21
+
22
+ def call(context)
23
+ context.task.start!
24
+ context.emit_status
25
+
26
+ input_text = context.message.text_content
27
+ reply = @network.run(message: input_text).last_text_content
28
+
29
+ artifact = ::A2A::Models::Artifact.new(
30
+ parts: [::A2A::Models::Part.text(reply.to_s)],
31
+ name: 'reply'
32
+ )
33
+ context.task.complete!(artifacts: [artifact])
34
+ context.emit_status(final: true)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ # Tracks live robot threads for in-progress interactive runs.
6
+ # Keyed by A2A task_id. Entries are removed when the robot thread finishes.
7
+ class Registry
8
+ Entry = Data.define(:thread, :event_queue, :answer_queue)
9
+
10
+ @mutex = Mutex.new
11
+ @entries = {}
12
+
13
+ class << self
14
+ def register(task_id, entry)
15
+ @mutex.synchronize { @entries[task_id] = entry }
16
+ end
17
+
18
+ def fetch(task_id)
19
+ @mutex.synchronize { @entries[task_id] }
20
+ end
21
+
22
+ def delete(task_id)
23
+ @mutex.synchronize { @entries.delete(task_id) }
24
+ end
25
+
26
+ def size
27
+ @mutex.synchronize { @entries.size }
28
+ end
29
+
30
+ def clear
31
+ @mutex.synchronize { @entries.clear }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ # Wraps a RobotLab::Robot as an A2A AgentExecutor.
6
+ #
7
+ # Pass an instance as the executor: when building an A2A server:
8
+ #
9
+ # adapter = RobotAdapter.new(robot, interactive: :none)
10
+ # A2A.server(agent_card: card, executor: adapter).run
11
+ #
12
+ # interactive modes:
13
+ # :none — robot runs sync; AskUser would block stdin (not recommended)
14
+ # :a2a_tool — A2A::AskUserTool replaces AskUser; Thread+Queue bridge
15
+ # :io_bridge — IoBridge replaces robot.input/output; Thread+Queue bridge
16
+ #
17
+ # Interactive modes keep the robot thread alive between A2A INPUT_REQUIRED
18
+ # and resume. Only works with in-process (Memory) storage.
19
+ class RobotAdapter < ::A2A::Server::AgentExecutor
20
+ VALID_MODES = %i[none a2a_tool io_bridge].freeze
21
+
22
+ def initialize(robot, interactive: :none)
23
+ super()
24
+ unless VALID_MODES.include?(interactive)
25
+ raise ArgumentError, "interactive must be one of #{VALID_MODES.inspect}"
26
+ end
27
+
28
+ @robot = robot
29
+ @interactive = interactive
30
+ end
31
+
32
+ def call(context)
33
+ input_text = context.message.text_content
34
+ task_id = context.task.id
35
+
36
+ case @interactive
37
+ when :none
38
+ run_simple(context, input_text)
39
+ when :a2a_tool, :io_bridge
40
+ run_interactive(context, input_text, task_id)
41
+ end
42
+ end
43
+
44
+ def cancel(context)
45
+ task_id = context.task.id
46
+ entry = Registry.fetch(task_id)
47
+ if entry
48
+ entry.thread.kill
49
+ Registry.delete(task_id)
50
+ end
51
+ super
52
+ end
53
+
54
+ private
55
+
56
+ # ── :none mode ────────────────────────────────────────────
57
+
58
+ def run_simple(context, input_text)
59
+ context.task.start!
60
+ context.emit_status
61
+
62
+ reply = @robot.run(input_text).reply
63
+ artifact = ::A2A::Models::Artifact.new(
64
+ parts: [::A2A::Models::Part.text(reply.to_s)],
65
+ name: 'reply'
66
+ )
67
+ context.task.complete!(artifacts: [artifact])
68
+ context.emit_status(final: true)
69
+ end
70
+
71
+ # ── interactive modes ─────────────────────────────────────
72
+
73
+ def run_interactive(context, input_text, task_id)
74
+ if context.is_a?(::A2A::Server::ResumeContext)
75
+ entry = Registry.fetch(task_id)
76
+ if entry
77
+ entry.answer_queue.push(context.resume_message.text_content)
78
+ monitor_task(context, entry, task_id)
79
+ else
80
+ context.task.fail!(message: 'No active session found for resumed task')
81
+ context.emit_status(final: true)
82
+ end
83
+ else
84
+ start_interactive_run(context, input_text, task_id)
85
+ end
86
+ end
87
+
88
+ def start_interactive_run(context, input_text, task_id)
89
+ event_queue = Queue.new
90
+ answer_queue = Queue.new
91
+ robot = @robot
92
+
93
+ context.task.start!
94
+ context.emit_status
95
+
96
+ thread = Thread.new do
97
+ setup_interactive_mode(robot, event_queue, answer_queue)
98
+ reply = robot.run(input_text).reply
99
+ event_queue.push({ type: :done, reply: reply })
100
+ rescue StandardError => e
101
+ event_queue.push({ type: :error, error: e })
102
+ ensure
103
+ teardown_interactive_mode(robot)
104
+ Registry.delete(task_id)
105
+ end
106
+
107
+ entry = Registry::Entry.new(
108
+ thread: thread,
109
+ event_queue: event_queue,
110
+ answer_queue: answer_queue
111
+ )
112
+ Registry.register(task_id, entry)
113
+
114
+ monitor_task(context, entry, task_id)
115
+ end
116
+
117
+ def monitor_task(context, entry, _task_id)
118
+ event = entry.event_queue.pop
119
+ case event[:type]
120
+ when :ask
121
+ context.task.require_input!(message: event[:prompt])
122
+ context.emit_status(final: true)
123
+ when :done
124
+ artifact = ::A2A::Models::Artifact.new(
125
+ parts: [::A2A::Models::Part.text(event[:reply].to_s)],
126
+ name: 'reply'
127
+ )
128
+ context.task.complete!(artifacts: [artifact])
129
+ context.emit_status(final: true)
130
+ when :error
131
+ context.task.fail!(message: event[:error].message)
132
+ context.emit_status(final: true)
133
+ end
134
+ end
135
+
136
+ # ── interactive setup / teardown ──────────────────────────
137
+
138
+ def setup_interactive_mode(robot, event_queue, answer_queue)
139
+ case @interactive
140
+ when :a2a_tool then inject_ask_user_tool(robot, event_queue, answer_queue)
141
+ when :io_bridge then inject_io_bridge(robot, event_queue, answer_queue)
142
+ end
143
+ end
144
+
145
+ def teardown_interactive_mode(robot)
146
+ case @interactive
147
+ when :a2a_tool
148
+ restore_tools(robot)
149
+ when :io_bridge
150
+ robot.input = nil
151
+ robot.output = nil
152
+ end
153
+ end
154
+
155
+ def inject_ask_user_tool(robot, event_queue, answer_queue)
156
+ tool = AskUserTool.new(robot: robot)
157
+ tool.event_queue = event_queue
158
+ tool.answer_queue = answer_queue
159
+
160
+ without_ask_user = robot.local_tools.reject do |t|
161
+ t.is_a?(RobotLab::AskUser) || t.is_a?(AskUserTool)
162
+ end
163
+ robot.instance_variable_set(:@local_tools, without_ask_user + [tool])
164
+ robot.instance_variable_set(:@_a2a_injected_tool, tool)
165
+ end
166
+
167
+ def restore_tools(robot)
168
+ injected = robot.instance_variable_get(:@_a2a_injected_tool)
169
+ return unless injected
170
+
171
+ cleaned = robot.local_tools.reject { |t| t.equal?(injected) }
172
+ robot.instance_variable_set(:@local_tools, cleaned)
173
+ robot.remove_instance_variable(:@_a2a_injected_tool)
174
+ end
175
+
176
+ def inject_io_bridge(robot, event_queue, answer_queue)
177
+ bridge = IoBridge.new(event_queue: event_queue, answer_queue: answer_queue)
178
+ robot.input = bridge
179
+ robot.output = bridge
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ # Thin builder that registers RobotLab robots and networks as A2A agents
6
+ # and delegates to simple_a2a for HTTP serving.
7
+ #
8
+ # Each robot or network is mounted at its own URL path. The path defaults
9
+ # to "/dns-label-of-name" and can be overridden with the path: keyword.
10
+ #
11
+ # @example Basic usage
12
+ # server = RobotLab::A2A::Server.new
13
+ # server.add_robot(my_robot)
14
+ # server.run(port: 9292)
15
+ #
16
+ # @example Interactive robot (AskUser bridged to A2A input_required)
17
+ # server = RobotLab::A2A::Server.new(interactive: :a2a_tool)
18
+ # server.add_robot(my_robot)
19
+ # server.run(port: 9292)
20
+ #
21
+ # @example Multiple agents
22
+ # server = RobotLab::A2A::Server.new
23
+ # server.add_robot(analyst, path: "/analyst")
24
+ # server.add_robot(writer, path: "/writer")
25
+ # server.add_network(pipeline, name: "pipeline", path: "/pipeline")
26
+ # server.run(port: 9292)
27
+ #
28
+ # @example Rack/Rails mount
29
+ # map "/agents" do
30
+ # run RobotLab::A2A::Server.new.add_robot(my_robot).to_app
31
+ # end
32
+ class Server
33
+ DEFAULT_VERSION = '1.0'
34
+
35
+ def initialize(host: 'localhost', port: 9292, storage: nil, interactive: :none)
36
+ @host = host
37
+ @port = port
38
+ @storage = storage || ::A2A::Storage::Memory.new
39
+ @interactive = interactive
40
+ @agents = {}
41
+ end
42
+
43
+ # Register a robot as an A2A agent.
44
+ #
45
+ # @param robot [RobotLab::Robot]
46
+ # @param name [String, nil] overrides robot.name
47
+ # @param description [String, nil] overrides robot.description
48
+ # @param path [String, nil] URL path prefix (defaults to "/dns-label")
49
+ # @return [self] for chaining
50
+ def add_robot(robot, name: nil, description: nil, path: nil)
51
+ agent_name = (name || robot.name).to_s
52
+ agent_desc = description || robot.description || agent_name
53
+ agent_path = path || "/#{dns_label(agent_name)}"
54
+ adapter = RobotAdapter.new(robot, interactive: @interactive)
55
+ card = build_agent_card(agent_name, agent_desc, path: agent_path)
56
+
57
+ @agents[agent_path] = { agent_card: card, executor: adapter, storage: @storage }
58
+ self
59
+ end
60
+
61
+ # Register a network as a single A2A agent (non-interactive).
62
+ #
63
+ # @param network [RobotLab::Network]
64
+ # @param name [String] required — used for agent card and default path
65
+ # @param description [String, nil]
66
+ # @param path [String, nil] URL path prefix (defaults to "/dns-label")
67
+ # @return [self] for chaining
68
+ def add_network(network, name:, description: nil, path: nil)
69
+ if @interactive != :none
70
+ raise ArgumentError,
71
+ 'NetworkAdapter only supports interactive: :none. ' \
72
+ 'Wrap individual robots with RobotAdapter for interactive flows.'
73
+ end
74
+
75
+ agent_name = name.to_s
76
+ agent_desc = description || agent_name
77
+ agent_path = path || "/#{dns_label(agent_name)}"
78
+ adapter = NetworkAdapter.new(network, interactive: :none)
79
+ card = build_agent_card(agent_name, agent_desc, path: agent_path)
80
+
81
+ @agents[agent_path] = { agent_card: card, executor: adapter, storage: @storage }
82
+ self
83
+ end
84
+
85
+ # Start the Falcon HTTP server.
86
+ def run
87
+ ::A2A.multi_server(agents: @agents, host: @host, port: @port).run
88
+ end
89
+
90
+ # Return a Rack app for embedding in other servers (e.g. Rails, Puma).
91
+ def to_app
92
+ url_map = @agents.transform_values do |cfg|
93
+ ::A2A.server(
94
+ agent_card: cfg[:agent_card],
95
+ executor: cfg[:executor],
96
+ storage: cfg[:storage]
97
+ ).rack_app
98
+ end
99
+ Rack::URLMap.new(url_map)
100
+ end
101
+
102
+ private
103
+
104
+ def build_agent_card(name, description, path:)
105
+ ::A2A::Models::AgentCard.new(
106
+ name: name,
107
+ description: description,
108
+ version: DEFAULT_VERSION,
109
+ capabilities: ::A2A::Models::AgentCapabilities.new,
110
+ skills: [::A2A::Models::AgentSkill.new(name: 'chat', description: 'General conversation')],
111
+ interfaces: [
112
+ ::A2A::Models::AgentInterface.new(
113
+ type: 'A2A',
114
+ url: "http://#{@host}:#{@port}#{path}",
115
+ version: DEFAULT_VERSION
116
+ )
117
+ ]
118
+ )
119
+ end
120
+
121
+ # Convert a Ruby-style name to RFC 1123 DNS label format.
122
+ # Underscores become hyphens; characters outside [a-z0-9-] are dropped.
123
+ def dns_label(name)
124
+ name.to_s.downcase.gsub('_', '-').gsub(/[^a-z0-9-]/, '').gsub(/\A-+|-+\z/, '')
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module A2A
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'robot_lab'
4
+ require 'simple_a2a'
5
+ require 'rack'
6
+
7
+ require_relative 'a2a/version'
8
+ require_relative 'a2a/registry'
9
+ require_relative 'a2a/ask_user_tool'
10
+ require_relative 'a2a/io_bridge'
11
+ require_relative 'a2a/robot_adapter'
12
+ require_relative 'a2a/network_adapter'
13
+ require_relative 'a2a/server'
14
+
15
+ module RobotLab
16
+ @_extensions = {} unless instance_variable_defined?(:@_extensions)
17
+
18
+ class << self
19
+ unless method_defined?(:register_extension) || respond_to?(:register_extension)
20
+ def register_extension(name, mod)
21
+ @_extensions[name.to_sym] = mod
22
+ end
23
+ end
24
+
25
+ unless method_defined?(:extension_loaded?) || respond_to?(:extension_loaded?)
26
+ def extension_loaded?(name)
27
+ @_extensions.key?(name.to_sym)
28
+ end
29
+ end
30
+
31
+ unless method_defined?(:extension) || respond_to?(:extension)
32
+ def extension(name)
33
+ @_extensions[name.to_sym]
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ RobotLab.register_extension(:a2a, RobotLab::A2A)