robot_lab-a2a 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.rubocop.yml +26 -0
- data/CHANGELOG.md +24 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +106 -0
- data/Rakefile +104 -0
- data/docs/assets/images/architecture.png +0 -0
- data/docs/assets/images/architecture.svg +258 -0
- data/docs/examples.md +116 -0
- data/docs/getting-started.md +103 -0
- data/docs/index.md +23 -0
- data/docs/interactive-modes.md +104 -0
- data/docs/server-api.md +118 -0
- data/examples/01_sync_robot/client.rb +94 -0
- data/examples/01_sync_robot/server.rb +45 -0
- data/examples/02_interactive_a2a_tool/client.rb +144 -0
- data/examples/02_interactive_a2a_tool/server.rb +78 -0
- data/examples/03_robot_network/client.rb +83 -0
- data/examples/03_robot_network/server.rb +77 -0
- data/examples/04_io_bridge/client.rb +140 -0
- data/examples/04_io_bridge/server.rb +64 -0
- data/examples/05_multi_agent/client.rb +97 -0
- data/examples/05_multi_agent/server.rb +76 -0
- data/examples/06_rack_mount/client.rb +90 -0
- data/examples/06_rack_mount/config.ru +44 -0
- data/examples/06_rack_mount/server.rb +72 -0
- data/examples/common_config.rb +9 -0
- data/examples/run +112 -0
- data/lib/robot_lab/a2a/ask_user_tool.rb +43 -0
- data/lib/robot_lab/a2a/io_bridge.rb +75 -0
- data/lib/robot_lab/a2a/network_adapter.rb +38 -0
- data/lib/robot_lab/a2a/registry.rb +36 -0
- data/lib/robot_lab/a2a/robot_adapter.rb +183 -0
- data/lib/robot_lab/a2a/server.rb +128 -0
- data/lib/robot_lab/a2a/version.rb +7 -0
- data/lib/robot_lab/a2a.rb +39 -0
- data/mkdocs.yml +153 -0
- metadata +128 -0
data/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,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)
|