robot_lab-a2a 0.1.1 → 0.2.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b79d098ef9b24711b48d41af7140e3bf331594f427b5518814e4ff58cde38a2d
4
- data.tar.gz: e38b9117d0b2d2dae5f3483ff7dd826ef25e0f7525738cf30941aead87214f77
3
+ metadata.gz: 508cfed94ac09af2dc3aef1bdce245e1ec12e561dc1af58e1a11c37c362c3e9a
4
+ data.tar.gz: 7014425d58923f5cf00f74e96242f0f77890e4342ea1cd06022cbab8d13dbdad
5
5
  SHA512:
6
- metadata.gz: 13b98611d8ef81ce10a0bddb1010a932e4b52a032546beb44eb3791d94f82d02fb3691a40c42222e3931824278cf31511fa9ec2fa864243dcbacdcf7779a2895
7
- data.tar.gz: a284690e28a7967c3784bbeebee40fd4019ca9c0c1f712d48f6cf5c0e42b33cc41881ddb866e9f7f7ad77b399d52b35da2ee92d2e55d3bc059de77b20714e473
6
+ metadata.gz: 3604a8078469267732f02a7bd7d5bd52d00e1980a2bac92c38c7c495947d11404fe1dc1614f166589e3be6a75afa98d5889485ad01121fc87e14e1107911ada1
7
+ data.tar.gz: c87c24bf66e2d1490d3e51b4d6155e753d5f1e24083fa2a3c93b1341506c8927f813b509c72d82224d319931fdea3c4b3026c5a1109343478645a9830fb3356a
data/.envrc CHANGED
@@ -1 +1,3 @@
1
+ source_up
1
2
  export RR=`pwd`
3
+ export BUNDLE_GEMFILE=Gemfile
data/.loki ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # robot_lab-a2a — Agent2Agent protocol adapter over HTTP+SSE.
3
+
4
+ import_up "repo_dev.loki"
5
+
6
+ class Tasks
7
+ @@gem_name ||= "robot_lab-a2a".freeze
8
+
9
+ header "robot_lab-a2a v#{gem_version} — A2A protocol adapter"
10
+ end
@@ -0,0 +1,6 @@
1
+ 2 lib/robot_lab/a2a/ask_user_tool.rb
2
+ 1 lib/robot_lab/a2a/io_bridge.rb
3
+ 2 lib/robot_lab/a2a/network_adapter.rb
4
+ 2 lib/robot_lab/a2a/registry.rb
5
+ 7 lib/robot_lab/a2a/robot_adapter.rb
6
+ 8 lib/robot_lab/a2a/server.rb
data/.rubocop.yml CHANGED
@@ -1,29 +1 @@
1
- AllCops:
2
- NewCops: enable
3
- SuggestExtensions: false
4
- Exclude:
5
- - 'examples/**/*'
6
-
7
- Style/Documentation:
8
- Enabled: false
9
-
10
- Layout/ExtraSpacing:
11
- Enabled: false
12
-
13
- Metrics/MethodLength:
14
- Max: 25
15
- CountAsOne:
16
- - heredoc
17
- - array
18
- - hash
19
-
20
- Metrics/AbcSize:
21
- Max: 25
22
-
23
- Metrics/ClassLength:
24
- Max: 150
25
-
26
- Metrics/BlockLength:
27
- Exclude:
28
- - 'Rakefile'
29
- - '*.gemspec'
1
+ inherit_from: ../.rubocop-base.yml
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+ - `.loki` Asgard task file: `test`, `rubocop`, `rubocop_fix`, `flog`, `flay`, `quality`, `build`, `install`, `release`, and `console` tasks via the Asgard task runner
5
+ - `flay_check` Rake task: structural code duplication gate (mass threshold 50); integrated into the `quality` Rake task
6
+ - `flay` and `minitest-reporters` gems added to development dependencies
7
+ - `test_output.txt`, `flay_output.txt`, `flog_output.txt`, and `rubocop_output.txt` added to `.gitignore`
8
+
9
+ ### Changed
10
+ - `test/test_helper.rb`: test output redirected to `test_output.txt` via `$stdout` reassignment; `TerminalSummaryReporter` prints a single PASS/FAIL summary line to the terminal
11
+ - `Rakefile`: `rubocop` and `rubocop_fix` tasks removed (now owned by Asgard); `flay_check` integrated into the `quality` gate
12
+
13
+ ## [0.2.1] - 2026-05-19
14
+
3
15
  ### Added
4
16
 
5
17
  - Demo examples infrastructure: `examples/run` lifecycle script, shared `examples/common_config.rb`, and three complete demos:
@@ -13,6 +25,7 @@
13
25
  ### Changed
14
26
 
15
27
  - Interactive mode `:acp_tool` renamed to `:a2a_tool` across all source, tests, and documentation
28
+ - Version synchronized with robot_lab core 0.2.1
16
29
 
17
30
  ### Fixed
18
31
 
data/CLAUDE.md ADDED
@@ -0,0 +1,57 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Gem Does
6
+
7
+ `robot_lab-a2a` is an Agent2Agent (A2A) protocol adapter that exposes RobotLab robots and networks as A2A agents over HTTP+SSE. It bridges RobotLab's terminal-based `AskUser` tool to A2A's `input_required`/resume lifecycle, enabling multi-turn conversational flows without terminal dependency. It delegates HTTP serving to the `simple_a2a` gem.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ rake test # Run full test suite (default rake task)
13
+ rake build # Build the gem package
14
+ rake install # Install gem locally
15
+
16
+ bin/setup # Install dependencies
17
+ bin/console # IRB session with gem loaded
18
+
19
+ # Run a single test file
20
+ ruby -Ilib -Itest test/robot_lab/test_a2a.rb
21
+
22
+ # Run a single test by name
23
+ ruby -Ilib -Itest test/robot_lab/test_a2a.rb -n test_registry_stores_and_retrieves
24
+ ```
25
+
26
+ ## Architecture
27
+
28
+ All source lives under `lib/robot_lab/a2a/`. The gem uses an adapter pattern — the two adapters both implement the `A2A::Server::AgentExecutor` interface with a single `call(context)` method.
29
+
30
+ ### Key Abstractions
31
+
32
+ **Registry** (`registry.rb`) — Thread-safe singleton tracking active robot sessions across HTTP requests. Entries are `Data.define(:thread, :event_queue, :answer_queue)` stored by session ID. Required because A2A `tasks/send` resumes an in-flight robot thread.
33
+
34
+ **RobotAdapter** (`robot_adapter.rb`) — Wraps a `RobotLab::Robot` as an executor. Supports three interactive modes:
35
+ - `:none` — Synchronous, robot runs to completion with no input prompts.
36
+ - `:a2a_tool` — Injects `AskUserTool` into the robot's `@local_tools`, converting terminal blocking to Queue signaling. Restores original tools on teardown.
37
+ - `:io_bridge` — Replaces the robot's I/O streams with an `IoBridge` instance; works with robots that use `puts`/`gets` directly.
38
+
39
+ **AskUserTool** (`ask_user_tool.rb`) — Drop-in replacement for `RobotLab::AskUser`. On `call`, pushes `{type: :ask, prompt:}` to the event_queue then blocks on answer_queue until the A2A client sends a resume.
40
+
41
+ **IoBridge** (`io_bridge.rb`) — IO-compatible object. Buffers all writes; on `gets`, flushes the buffer as the prompt event and blocks on answer_queue. Enables interactive mode for robots that don't use the AskUser tool directly.
42
+
43
+ **NetworkAdapter** (`network_adapter.rb`) — Wraps a `RobotLab::Network`. Currently supports only `:none` mode; interactive mode per network node is not yet implemented.
44
+
45
+ **Server** (`server.rb`) — Fluent builder. `add_robot` / `add_network` register adapters keyed by DNS-safe path labels (underscores→hyphens, RFC 1123). `run(port:)` starts the HTTP server; `to_app` returns a Rack app for embedding in Rails/Puma.
46
+
47
+ ### Thread Safety
48
+
49
+ Interactive modes run each robot on its own Ruby Thread. `Registry` uses a `Mutex`. `AskUserTool` and `IoBridge` use Ruby's `Queue` for producer/consumer coordination between the robot thread and the HTTP request handler.
50
+
51
+ ### Extension Registration
52
+
53
+ The gem hooks into RobotLab's extension system at require time (`a2a.rb`) and registers itself as the `:a2a` extension.
54
+
55
+ ## Tests
56
+
57
+ Tests live in `test/robot_lab/test_a2a.rb` using Minitest with autorun. The test helper is `test/test_helper.rb`. There is no `.rspec` or RuboCop config — no linter is configured.
data/Rakefile CHANGED
@@ -6,7 +6,7 @@ require 'rake/testtask'
6
6
  Rake::TestTask.new(:test) do |t|
7
7
  t.libs << 'test'
8
8
  t.libs << 'lib'
9
- t.test_files = FileList['test/**/*_test.rb']
9
+ t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
10
10
  t.verbose = true
11
11
  t.ruby_opts << '-rtest_helper'
12
12
  end
@@ -24,16 +24,6 @@ task :test_file, [:file] do |_t, args|
24
24
  ruby "test/#{args[:file]}"
25
25
  end
26
26
 
27
- desc 'Check code style with RuboCop'
28
- task :rubocop do
29
- sh 'bundle exec rubocop'
30
- end
31
-
32
- desc 'Auto-correct RuboCop offenses'
33
- task :rubocop_fix do
34
- sh 'bundle exec rubocop -a'
35
- end
36
-
37
27
  desc 'Check code complexity with Flog (warn >=20, fail >=50)'
38
28
  task :flog_check do
39
29
  require 'flog'
@@ -71,34 +61,72 @@ task :flog_check do
71
61
  end
72
62
  end
73
63
 
74
- desc 'Run all quality checks: tests (with coverage), RuboCop, and Flog'
64
+ desc 'Check for structural code duplication with Flay (mass >= 50)'
65
+ task :flay_check do
66
+ require 'flay'
67
+
68
+ mass_threshold = 50
69
+
70
+ flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
71
+ flay.process(*Dir.glob('lib/**/*.rb'))
72
+ flay.analyze
73
+
74
+ if flay.hashes.empty?
75
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
76
+ else
77
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
78
+ flay.report
79
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
80
+ end
81
+ end
82
+
83
+ desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
75
84
  task :quality do
76
- results = {}
85
+ gates = [
86
+ ['Tests + Coverage', 'bundle exec rake test'],
87
+ ['RuboCop', 'bundle exec rubocop'],
88
+ ['Flog Complexity', 'bundle exec rake flog_check'],
89
+ ['Flay Duplication', 'bundle exec rake flay_check']
90
+ ]
91
+
92
+ results = gates.map do |label, command|
93
+ puts "\n#{'=' * 60}"
94
+ puts "Quality Gate: #{label}"
95
+ puts '=' * 60
96
+ [label, system(command) ? :pass : :fail]
97
+ end
77
98
 
78
- puts "\n#{'=' * 60}"
79
- puts 'Quality Gate: Tests + Coverage'
80
- puts '=' * 60
81
- results[:tests] = system('bundle exec rake test') ? :pass : :fail
99
+ green = ->(s) { "\e[32m#{s}\e[0m" }
100
+ red = ->(s) { "\e[31m#{s}\e[0m" }
101
+ width = results.map { |label, _| label.length }.max
82
102
 
83
103
  puts "\n#{'=' * 60}"
84
- puts 'Quality Gate: RuboCop'
104
+ puts 'Quality Gate Summary'
85
105
  puts '=' * 60
86
- results[:rubocop] = system('bundle exec rubocop') ? :pass : :fail
106
+ results.each do |label, status|
107
+ badge = status == :pass ? green.call('PASS') : red.call('FAIL')
108
+ puts " [#{badge}] #{label.ljust(width)}"
109
+ end
110
+ puts '-' * 60
87
111
 
88
- puts "\n#{'=' * 60}"
89
- puts 'Quality Gate: Flog Complexity'
112
+ passed = results.count { |_, s| s == :pass }
113
+ failed = results.count { |_, s| s == :fail }
114
+ tally = "#{passed} passed, #{failed} failed"
115
+ puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
90
116
  puts '=' * 60
91
- results[:flog] = system('bundle exec rake flog_check') ? :pass : :fail
92
117
 
93
- puts "\n#{'=' * 60}"
94
- puts 'Quality Summary'
95
- puts '=' * 60
96
- results.each do |gate, status|
97
- icon = status == :pass ? 'PASS' : 'FAIL'
98
- puts " [#{icon}] #{gate}"
118
+ abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
119
+ puts "\n#{green.call('All quality gates passed.')}"
120
+ end
121
+
122
+ namespace :docs do
123
+ desc 'Build MkDocs documentation'
124
+ task :build do
125
+ sh 'mkdocs build'
99
126
  end
100
- puts '=' * 60
101
127
 
102
- abort "\nQuality gate failed" if results.values.any?(:fail)
103
- puts "\nAll quality gates passed."
128
+ desc 'Serve MkDocs documentation locally on http://localhost:8000'
129
+ task :serve do
130
+ sh 'mkdocs serve'
131
+ end
104
132
  end
data/docs/examples.md CHANGED
@@ -5,6 +5,7 @@
5
5
  ```
6
6
  examples/
7
7
  run # launcher script
8
+ common_config.rb # shared $LOAD_PATH + require, loaded by every example
8
9
  01_sync_robot/
9
10
  server.rb # starts the A2A server
10
11
  client.rb # sends a task and prints the result
@@ -14,6 +15,16 @@ examples/
14
15
  03_robot_network/
15
16
  server.rb
16
17
  client.rb
18
+ 04_io_bridge/
19
+ server.rb
20
+ client.rb
21
+ 05_multi_agent/
22
+ server.rb
23
+ client.rb
24
+ 06_rack_mount/
25
+ server.rb
26
+ config.ru # standalone Rack/Rails mount reference (not used by run)
27
+ client.rb
17
28
  ```
18
29
 
19
30
  ## How to run
@@ -114,3 +125,46 @@ Sending task to network...
114
125
  Reply: [synthesised answer from the final pipeline stage]
115
126
  Done.
116
127
  ```
128
+
129
+ ## 04_io_bridge
130
+
131
+ **Demonstrates:** `:io_bridge` interactive mode. `QuoteRobot` has no knowledge of A2A at all — it just calls `@output.puts` and `@input.gets` (falling back to `$stdout`/`$stdin` when not injected), so the same class also runs fine interactively in a terminal. The server injects an `IoBridge` as `robot.input`/`robot.output` before each run.
132
+
133
+ **Two-turn flow:**
134
+
135
+ 1. Client sends a topic (`"stoicism"`) → `QuoteRobot` writes its question to the buffered output, then calls `gets` → `IoBridge` flushes the buffer as the `input_required` prompt and blocks.
136
+ 2. Client resumes with the same `task_id` and an answer (`"Ada"`) → `IoBridge` unblocks `gets` → the robot completes and returns a quote addressed to that name.
137
+
138
+ **What it shows:**
139
+
140
+ - `interactive: :io_bridge` server setup
141
+ - A robot written against plain Ruby IO, with no A2A-specific code
142
+ - The same two-turn `task_id` resume pattern as `:a2a_tool`
143
+
144
+ **Note:** Turn 2 requires a `simple_a2a` build with `ResumeContext` support (>= 0.3.1). On older versions the client still demonstrates Turn 1's `input_required` suspension.
145
+
146
+ ## 05_multi_agent
147
+
148
+ **Demonstrates:** Multiple independent A2A agents served from a single process via the fluent builder API with explicit `path:` overrides — `HeadlineRobot` at `/headline` and `TagRobot` at `/tags`.
149
+
150
+ **What it shows:**
151
+
152
+ - Chaining `add_robot` calls with explicit `path:` values on one `Server` instance
153
+ - Two separate `A2A.client` instances, one per agent path, each with its own agent card
154
+ - That co-located agents are indistinguishable from independently hosted ones at the protocol level
155
+
156
+ **Expected output (client):** both clients discover their agent card, send the same input text, and print each robot's distinct reply (a capitalised headline vs. a list of `#hashtag` keywords) alongside pass/fail assertions.
157
+
158
+ ## 06_rack_mount
159
+
160
+ **Demonstrates:** `server.to_app`, which returns a `Rack::URLMap` instead of starting a dedicated server — for embedding A2A agents inside a larger Rack application (Rails, Sinatra, Puma, etc.) rather than running `server.run` standalone.
161
+
162
+ The demo composes the A2A agent (`EchoRobot`, mounted via `to_app`) with a plain `/health` JSON Rack endpoint on the same combined app, run via `A2A::Server::FalconRunner`. `config.ru` in this directory is a copy-paste reference for mounting the same `to_app` result under Rackup or a Rails `config/routes.rb` — it is not used by `examples/run`.
163
+
164
+ **What it shows:**
165
+
166
+ - `add_robot(...).to_app` returning a `Rack::URLMap`
167
+ - Composing that map with unrelated Rack routes
168
+ - That both A2A and non-A2A routes work correctly side by side on the composed app
169
+
170
+ **Expected output (client):** a passing health check against `/health` followed by a normal A2A `send_task` round trip against `/echo-robot`, each reported with a pass/fail assertion.
@@ -102,3 +102,7 @@ RobotLab::A2A::Server.new(interactive: :io_bridge)
102
102
  | `:io_bridge` | `robot.run(text)` + settable `robot.input=` / `robot.output=` | Robots that interact via raw stdin/stdout streams |
103
103
 
104
104
  When in doubt, start with `:none`. Upgrade to `:a2a_tool` if your robot uses `AskUser`, or `:io_bridge` if it uses raw IO.
105
+
106
+ ## Cancelling an in-progress task
107
+
108
+ While a robot is suspended waiting on `answer_queue` (i.e. between an `input_required` event and the client's resume), the A2A client may send `tasks/cancel` instead of resuming. `RobotAdapter#cancel` looks the task up in `Registry` by task ID, kills the robot thread with `Thread#kill`, and removes the entry from `Registry` before delegating to the default `AgentExecutor#cancel` behaviour. If no entry is found (e.g. the task already completed or was never interactive), `cancel` is a no-op aside from the default behaviour.
data/docs/server-api.md CHANGED
@@ -27,8 +27,8 @@ Valid `interactive` values: `:none`, `:a2a_tool`, `:io_bridge`. See [Interactive
27
27
  ```ruby
28
28
  server.add_robot(
29
29
  robot,
30
- name: required, # String — human-readable agent name
31
- description: required, # String — agent capability description
30
+ name: nil, # String — human-readable agent name; defaults to robot.name
31
+ description: nil, # String — agent capability description; defaults to robot.description
32
32
  path: nil # String — URL path segment; derived from name if nil
33
33
  ) → self
34
34
  ```
@@ -51,12 +51,12 @@ Wraps `robot` in a `RobotAdapter` using the server's `interactive` setting and r
51
51
  server.add_network(
52
52
  network,
53
53
  name: required, # String — human-readable agent name
54
- description: required, # String — agent capability description
54
+ description: nil, # String — agent capability description; defaults to name
55
55
  path: nil # String — URL path segment; derived from name if nil
56
56
  ) → self
57
57
  ```
58
58
 
59
- Wraps `network` in a `NetworkAdapter`. Only `:none` mode is supported for networks; the `interactive` setting is ignored.
59
+ Wraps `network` in a `NetworkAdapter`, always constructed with `interactive: :none`. If the server's own `interactive` setting is anything other than `:none`, `add_network` raises `ArgumentError` instead of silently ignoring it — networks do not support interactive modes. Wrap individual robots with `RobotAdapter` (via `add_robot`) for interactive flows.
60
60
 
61
61
  **`network` must respond to:**
62
62
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RobotLab
4
4
  module A2A
5
- VERSION = '0.1.1'
5
+ VERSION = '0.2.6'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab-a2a
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -27,16 +27,16 @@ dependencies:
27
27
  name: robot_lab
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
30
+ - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 0.2.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 0.2.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: simple_a2a
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -63,8 +63,11 @@ extra_rdoc_files: []
63
63
  files:
64
64
  - ".envrc"
65
65
  - ".github/workflows/deploy-github-pages.yml"
66
+ - ".loki"
67
+ - ".quality/reek_baseline.txt"
66
68
  - ".rubocop.yml"
67
69
  - CHANGELOG.md
70
+ - CLAUDE.md
68
71
  - COMMITS.md
69
72
  - LICENSE.txt
70
73
  - README.md
@@ -122,7 +125,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
125
  - !ruby/object:Gem::Version
123
126
  version: '0'
124
127
  requirements: []
125
- rubygems_version: 4.0.11
128
+ rubygems_version: 4.0.17
126
129
  specification_version: 4
127
130
  summary: Agent2Agent (A2A) protocol adapter for RobotLab
128
131
  test_files: []