a2a-rails 0.1.0 → 0.2.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +262 -205
  4. data/app/controllers/a2a/rails/agent_cards_controller.rb +3 -0
  5. data/app/controllers/a2a/rails/requests_controller.rb +53 -0
  6. data/lib/a2a/rails/agent.rb +33 -2
  7. data/lib/a2a/rails/agent_card/builder.rb +3 -1
  8. data/lib/a2a/rails/agent_card/security.rb +115 -0
  9. data/lib/a2a/rails/authentication.rb +71 -0
  10. data/lib/a2a/rails/configuration.rb +95 -1
  11. data/lib/a2a/rails/dispatcher.rb +36 -4
  12. data/lib/a2a/rails/engine.rb +9 -0
  13. data/lib/a2a/rails/errors.rb +3 -0
  14. data/lib/a2a/rails/execution_plan.rb +40 -0
  15. data/lib/a2a/rails/file_artifact.rb +77 -0
  16. data/lib/a2a/rails/protocol/agent2agent_adapter.rb +11 -0
  17. data/lib/a2a/rails/protocol/request_handler.rb +124 -13
  18. data/lib/a2a/rails/protocol/task_mapper.rb +21 -0
  19. data/lib/a2a/rails/request_guard.rb +76 -0
  20. data/lib/a2a/rails/runtime.rb +15 -6
  21. data/lib/a2a/rails/skill.rb +7 -2
  22. data/lib/a2a/rails/task/active_record_store.rb +451 -0
  23. data/lib/a2a/rails/task/artifact_mapper.rb +2 -0
  24. data/lib/a2a/rails/task/lifecycle.rb +18 -8
  25. data/lib/a2a/rails/task/memory_store.rb +39 -15
  26. data/lib/a2a/rails/task/store.rb +16 -4
  27. data/lib/a2a/rails/task_execution_arguments.rb +78 -0
  28. data/lib/a2a/rails/task_execution_job.rb +79 -0
  29. data/lib/a2a/rails/version.rb +1 -1
  30. data/lib/a2a-rails.rb +7 -0
  31. data/lib/generators/a2a/rails/task_store_generator.rb +46 -0
  32. data/lib/generators/a2a/rails/templates/create_a2a_rails_tasks.rb.tt +31 -0
  33. data/lib/generators/a2a/rails/templates/initializer.rb.tt +4 -0
  34. data/lib/tasks/a2a_rails_tasks.rake +54 -0
  35. metadata +94 -3
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ class TaskExecutionArguments
6
+ MAX_IDENTIFIER_BYTES = 256
7
+ MAX_CLASS_NAME_BYTES = 512
8
+ KEYS = %i[task_id principal_id agent_class_name skill_id].freeze
9
+
10
+ attr_reader(*KEYS)
11
+
12
+ def initialize(task_id:, principal_id:, agent_class_name:, skill_id:)
13
+ @task_id = normalize_identifier(task_id, "task_id")
14
+ @principal_id = normalize_principal_id(principal_id)
15
+ @agent_class_name = normalize_agent_class_name(agent_class_name)
16
+ @skill_id = normalize_skill_id(skill_id)
17
+ @payload = {
18
+ task_id: @task_id,
19
+ principal_id: @principal_id,
20
+ agent_class_name: @agent_class_name,
21
+ skill_id: @skill_id
22
+ }.freeze
23
+ freeze
24
+ end
25
+
26
+ def to_h
27
+ @payload
28
+ end
29
+
30
+ private
31
+
32
+ def normalize_identifier(value, label)
33
+ unless safe_string?(value, max_bytes: MAX_IDENTIFIER_BYTES)
34
+ raise ConfigurationError, "#{label} must be a non-empty safe String"
35
+ end
36
+
37
+ value.dup.freeze
38
+ end
39
+
40
+ def normalize_principal_id(value)
41
+ return nil if value.nil?
42
+
43
+ unless Authentication.valid_principal_id?(value)
44
+ raise ConfigurationError, "principal_id must be a valid non-secret principal ID String"
45
+ end
46
+
47
+ value.dup.freeze
48
+ end
49
+
50
+ def normalize_agent_class_name(value)
51
+ return nil if value.nil?
52
+
53
+ unless safe_string?(value, max_bytes: MAX_CLASS_NAME_BYTES)
54
+ raise ConfigurationError, "agent_class_name must be a safe class-name String"
55
+ end
56
+
57
+ parts = value.sub(/\A::/, "").split("::")
58
+ unless parts.all? { |part| part.match?(/\A[A-Z]\w*\z/) }
59
+ raise ConfigurationError, "agent_class_name must be a valid class-name String"
60
+ end
61
+
62
+ value.dup.freeze
63
+ end
64
+
65
+ def normalize_skill_id(value)
66
+ string = value.is_a?(Symbol) ? value.to_s : value
67
+ normalize_identifier(string, "skill_id")
68
+ end
69
+
70
+ def safe_string?(value, max_bytes:)
71
+ value.is_a?(String) &&
72
+ !value.strip.empty? &&
73
+ value.bytesize <= max_bytes &&
74
+ !value.match?(/[[:cntrl:]]/)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module A2A
6
+ module Rails
7
+ class TaskExecutionJob < ::ActiveJob::Base
8
+ queue_as :default
9
+ # The Task is already persisted before enqueue. Keep the enqueue boundary
10
+ # immediate and independent from any surrounding host Active Record transaction.
11
+ self.enqueue_after_transaction_commit = false
12
+
13
+ def perform(task_id:, principal_id:, agent_class_name:, skill_id:)
14
+ arguments = TaskExecutionArguments.new(
15
+ task_id: task_id,
16
+ principal_id: principal_id,
17
+ agent_class_name: agent_class_name,
18
+ skill_id: skill_id
19
+ )
20
+ task_id = arguments.task_id
21
+ principal_id = arguments.principal_id
22
+ agent_class_name = arguments.agent_class_name
23
+ skill_id = arguments.skill_id
24
+
25
+ configuration = A2A::Rails.configuration
26
+ lifecycle = Task::Lifecycle.new(
27
+ store: A2A::Rails.runtime.task_store,
28
+ logger: configuration.logger,
29
+ principal_id: principal_id
30
+ )
31
+
32
+ claimed = lifecycle.claim_execution(task_id)
33
+ return unless claimed
34
+
35
+ agent = configuration.resolve_agent
36
+ unless agent_class_name.nil? || agent.name == agent_class_name
37
+ raise ConfigurationError, "Queued Agent no longer matches configured Agent"
38
+ end
39
+
40
+ message = original_message(claimed)
41
+ plan = ExecutionPlan.new(
42
+ agent_class_name: agent_class_name,
43
+ skill_id: skill_id,
44
+ execution_mode: :async
45
+ )
46
+ context = {
47
+ task_id: task_id,
48
+ context_id: claimed.fetch(:context_id),
49
+ principal_id: principal_id,
50
+ idempotency_key: task_id
51
+ }
52
+
53
+ result = Dispatcher.new(agent: agent, configuration: configuration).execute(
54
+ plan: plan,
55
+ message: message,
56
+ context: context
57
+ )
58
+ lifecycle.complete(task_id, result)
59
+ rescue RejectedTask => error
60
+ lifecycle&.reject(task_id, error)
61
+ rescue TaskNotFoundError
62
+ nil
63
+ rescue StandardError => error
64
+ lifecycle&.fail(task_id, error) if lifecycle
65
+ end
66
+
67
+ private
68
+
69
+ def original_message(task)
70
+ history = task[:history]
71
+ unless history.is_a?(Array) && history.first.is_a?(Hash)
72
+ raise ConfigurationError, "Queued Task does not contain an original Message"
73
+ end
74
+
75
+ history.first
76
+ end
77
+ end
78
+ end
79
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A2A
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/a2a-rails.rb CHANGED
@@ -2,13 +2,20 @@
2
2
 
3
3
  require_relative "a2a/rails/version"
4
4
  require_relative "a2a/rails/errors"
5
+ require_relative "a2a/rails/file_artifact"
6
+ require_relative "a2a/rails/authentication"
7
+ require_relative "a2a/rails/request_guard"
5
8
  require_relative "a2a/rails/configuration"
6
9
  require_relative "a2a/rails/skill"
7
10
  require_relative "a2a/rails/agent"
11
+ require_relative "a2a/rails/execution_plan"
8
12
  require_relative "a2a/rails/dispatcher"
13
+ require_relative "a2a/rails/agent_card/security"
9
14
  require_relative "a2a/rails/agent_card/validator"
10
15
  require_relative "a2a/rails/agent_card/builder"
11
16
  require_relative "a2a/rails/task"
17
+ require_relative "a2a/rails/task_execution_arguments"
18
+ require_relative "a2a/rails/task_execution_job"
12
19
  require_relative "a2a/rails/protocol/adapter"
13
20
  require_relative "a2a/rails/protocol/task_mapper"
14
21
  require_relative "a2a/rails/protocol/request_handler"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rails/generators"
5
+ require "rails/generators/migration"
6
+ require "active_record"
7
+ require "rails/generators/active_record"
8
+ rescue LoadError => error
9
+ raise LoadError,
10
+ "a2a:rails:task_store requires ActiveRecord in the host application (#{error.path})"
11
+ end
12
+
13
+ module A2A
14
+ module Rails
15
+ class TaskStoreGenerator < ::Rails::Generators::Base
16
+ include ::Rails::Generators::Migration
17
+
18
+ namespace "a2a:rails:task_store"
19
+ source_root File.expand_path("templates", __dir__)
20
+
21
+ def self.next_migration_number(dirname)
22
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
23
+ end
24
+
25
+ def create_task_store_migration
26
+ migration_template(
27
+ "create_a2a_rails_tasks.rb.tt",
28
+ "db/migrate/create_a2a_rails_tasks.rb"
29
+ )
30
+ end
31
+
32
+ def show_configuration
33
+ say <<~MESSAGE
34
+
35
+ Configure the durable store in config/initializers/a2a_rails.rb:
36
+
37
+ config.task_store = :active_record
38
+
39
+ Then run:
40
+
41
+ bin/rails db:migrate
42
+ MESSAGE
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ class <%= ::ActiveSupport::Inflector.camelize("create_a2a_rails_tasks") %> < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :a2a_rails_tasks do |t|
4
+ t.string :task_id, null: false
5
+ t.string :owner_id
6
+ t.string :context_id, null: false
7
+ t.string :state, null: false
8
+ t.datetime :status_timestamp, precision: 6, null: false
9
+ t.json :status_message
10
+ t.json :history
11
+ t.json :artifacts
12
+ t.datetime :expires_at, precision: 6
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :a2a_rails_tasks, :task_id,
18
+ unique: true,
19
+ name: "idx_a2a_tasks_task_id"
20
+ add_index :a2a_rails_tasks, [:owner_id, :task_id],
21
+ name: "idx_a2a_tasks_owner_task"
22
+ add_index :a2a_rails_tasks, [:owner_id, :context_id, :status_timestamp],
23
+ name: "idx_a2a_tasks_owner_context_time"
24
+ add_index :a2a_rails_tasks, [:owner_id, :state, :status_timestamp],
25
+ name: "idx_a2a_tasks_owner_state_time"
26
+ add_index :a2a_rails_tasks, [:owner_id, :status_timestamp],
27
+ name: "idx_a2a_tasks_owner_time"
28
+ add_index :a2a_rails_tasks, :expires_at,
29
+ name: "idx_a2a_tasks_expires_at"
30
+ end
31
+ end
@@ -1,4 +1,8 @@
1
1
  A2A::Rails.configure do |config|
2
2
  config.agent = "YourAgent"
3
3
  config.public_base_url = ENV["A2A_PUBLIC_BASE_URL"]
4
+
5
+ # Production/staging A2A requests fail closed unless the host configures
6
+ # config.authenticate_request and matching Agent Card security metadata.
7
+ # See: https://github.com/cuichangquan/a2a-rails/blob/main/docs/guides/authentication.md
4
8
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :a2a do
4
+ namespace :rails do
5
+ namespace :tasks do
6
+ desc "Delete expired a2a-rails Tasks in bounded batches"
7
+ task prune: :environment do
8
+ store = A2A::Rails.configuration.resolve_task_store
9
+ unless store.respond_to?(:prune_expired)
10
+ abort "Configured a2a-rails Task Store does not support pruning"
11
+ end
12
+
13
+ max_batches = Integer(ENV.fetch("A2A_TASK_PRUNE_MAX_BATCHES", "100"), 10)
14
+ abort "A2A_TASK_PRUNE_MAX_BATCHES must be positive" unless max_batches.positive?
15
+
16
+ total = 0
17
+ batches = 0
18
+
19
+ while batches < max_batches
20
+ deleted = store.prune_expired
21
+ break if deleted.zero?
22
+
23
+ total += deleted
24
+ batches += 1
25
+ end
26
+
27
+ stats = store.respond_to?(:maintenance_stats) ? store.maintenance_stats : {}
28
+ remaining = stats[:expired]
29
+
30
+ puts "a2a-rails Task prune: deleted=#{total} batches=#{batches}" +
31
+ (remaining.nil? ? "" : " expired_remaining=#{remaining}")
32
+ rescue ArgumentError => error
33
+ abort error.message
34
+ end
35
+
36
+ desc "Print aggregate a2a-rails Task maintenance counts"
37
+ task stats: :environment do
38
+ store = A2A::Rails.configuration.resolve_task_store
39
+ unless store.respond_to?(:maintenance_stats)
40
+ abort "Configured a2a-rails Task Store does not support maintenance stats"
41
+ end
42
+
43
+ stats = store.maintenance_stats
44
+ puts [
45
+ "a2a-rails Task stats:",
46
+ "total=#{stats.fetch(:total)}",
47
+ "active=#{stats.fetch(:active)}",
48
+ "terminal=#{stats.fetch(:terminal)}",
49
+ "expired=#{stats.fetch(:expired)}"
50
+ ].join(" ")
51
+ end
52
+ end
53
+ end
54
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: a2a-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - cuichangquan
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-06 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: agent2agent
@@ -43,6 +43,26 @@ dependencies:
43
43
  - - "<"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '8.2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: activejob
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '8.0'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '8.2'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '8.0'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '8.2'
46
66
  - !ruby/object:Gem::Dependency
47
67
  name: json
48
68
  requirement: !ruby/object:Gem::Requirement
@@ -97,6 +117,26 @@ dependencies:
97
117
  - - "<"
98
118
  - !ruby/object:Gem::Version
99
119
  version: '8.2'
120
+ - !ruby/object:Gem::Dependency
121
+ name: activerecord
122
+ requirement: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '8.0'
127
+ - - "<"
128
+ - !ruby/object:Gem::Version
129
+ version: '8.2'
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '8.0'
137
+ - - "<"
138
+ - !ruby/object:Gem::Version
139
+ version: '8.2'
100
140
  - !ruby/object:Gem::Dependency
101
141
  name: minitest
102
142
  requirement: !ruby/object:Gem::Requirement
@@ -125,6 +165,46 @@ dependencies:
125
165
  - - "~>"
126
166
  - !ruby/object:Gem::Version
127
167
  version: '13.2'
168
+ - !ruby/object:Gem::Dependency
169
+ name: sqlite3
170
+ requirement: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '2.1'
175
+ - - "<"
176
+ - !ruby/object:Gem::Version
177
+ version: '3'
178
+ type: :development
179
+ prerelease: false
180
+ version_requirements: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '2.1'
185
+ - - "<"
186
+ - !ruby/object:Gem::Version
187
+ version: '3'
188
+ - !ruby/object:Gem::Dependency
189
+ name: puma
190
+ requirement: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '6.6'
195
+ - - "<"
196
+ - !ruby/object:Gem::Version
197
+ version: '8'
198
+ type: :development
199
+ prerelease: false
200
+ version_requirements: !ruby/object:Gem::Requirement
201
+ requirements:
202
+ - - ">="
203
+ - !ruby/object:Gem::Version
204
+ version: '6.6'
205
+ - - "<"
206
+ - !ruby/object:Gem::Version
207
+ version: '8'
128
208
  description: Expose Rails applications as A2A agents while keeping protocol SDK details
129
209
  behind an internal adapter boundary.
130
210
  executables: []
@@ -141,28 +221,39 @@ files:
141
221
  - lib/a2a-rails.rb
142
222
  - lib/a2a/rails/agent.rb
143
223
  - lib/a2a/rails/agent_card/builder.rb
224
+ - lib/a2a/rails/agent_card/security.rb
144
225
  - lib/a2a/rails/agent_card/validator.rb
226
+ - lib/a2a/rails/authentication.rb
145
227
  - lib/a2a/rails/configuration.rb
146
228
  - lib/a2a/rails/dispatcher.rb
147
229
  - lib/a2a/rails/engine.rb
148
230
  - lib/a2a/rails/errors.rb
231
+ - lib/a2a/rails/execution_plan.rb
232
+ - lib/a2a/rails/file_artifact.rb
149
233
  - lib/a2a/rails/protocol/adapter.rb
150
234
  - lib/a2a/rails/protocol/agent2agent_adapter.rb
151
235
  - lib/a2a/rails/protocol/request_handler.rb
152
236
  - lib/a2a/rails/protocol/task_mapper.rb
237
+ - lib/a2a/rails/request_guard.rb
153
238
  - lib/a2a/rails/runtime.rb
154
239
  - lib/a2a/rails/skill.rb
155
240
  - lib/a2a/rails/task.rb
241
+ - lib/a2a/rails/task/active_record_store.rb
156
242
  - lib/a2a/rails/task/artifact_mapper.rb
157
243
  - lib/a2a/rails/task/lifecycle.rb
158
244
  - lib/a2a/rails/task/memory_store.rb
159
245
  - lib/a2a/rails/task/result_mapper.rb
160
246
  - lib/a2a/rails/task/store.rb
247
+ - lib/a2a/rails/task_execution_arguments.rb
248
+ - lib/a2a/rails/task_execution_job.rb
161
249
  - lib/a2a/rails/version.rb
162
250
  - lib/generators/a2a/rails/agent_generator.rb
163
251
  - lib/generators/a2a/rails/install_generator.rb
252
+ - lib/generators/a2a/rails/task_store_generator.rb
164
253
  - lib/generators/a2a/rails/templates/agent.rb.tt
254
+ - lib/generators/a2a/rails/templates/create_a2a_rails_tasks.rb.tt
165
255
  - lib/generators/a2a/rails/templates/initializer.rb.tt
256
+ - lib/tasks/a2a_rails_tasks.rake
166
257
  homepage: https://github.com/cuichangquan/a2a-rails
167
258
  licenses:
168
259
  - MIT
@@ -186,7 +277,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
186
277
  - !ruby/object:Gem::Version
187
278
  version: '0'
188
279
  requirements: []
189
- rubygems_version: 3.6.4
280
+ rubygems_version: 3.6.9
190
281
  specification_version: 4
191
282
  summary: Rails-native integration for exposing Rails applications as A2A agents
192
283
  test_files: []