a2a-rails 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.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+ require "time"
6
+ require_relative "store"
7
+
8
+ module A2A
9
+ module Rails
10
+ module Task
11
+ class MemoryStore < Store
12
+ UNSET = Object.new.freeze
13
+ DEFAULT_PAGE_SIZE = 50
14
+ MAX_PAGE_SIZE = 100
15
+
16
+ def initialize
17
+ @tasks = {}
18
+ @pages = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def save(task)
23
+ id = task.fetch(:id)
24
+ @mutex.synchronize do
25
+ @tasks[id] = copy(task)
26
+ copy(@tasks[id])
27
+ end
28
+ end
29
+
30
+ def find(task_id)
31
+ @mutex.synchronize { copy(find!(task_id)) }
32
+ end
33
+
34
+ def transition(task_id, state:, timestamp: Time.now.utc, artifacts: UNSET, message: UNSET)
35
+ validate_state!(state)
36
+
37
+ @mutex.synchronize do
38
+ task = find!(task_id)
39
+ return copy(task) if terminal?(task)
40
+
41
+ task[:status] = status_hash(state, timestamp, message)
42
+ task[:artifacts] = copy(artifacts) unless artifacts.equal?(UNSET)
43
+ copy(task)
44
+ end
45
+ end
46
+
47
+ def cancel(task_id, timestamp: Time.now.utc)
48
+ @mutex.synchronize do
49
+ task = find!(task_id)
50
+ state = task.dig(:status, :state)
51
+ raise TaskNotCancelableError.new(task_id, state: state) if TERMINAL_STATES.include?(state)
52
+
53
+ task[:status] = status_hash(:canceled, timestamp, UNSET)
54
+ copy(task)
55
+ end
56
+ end
57
+
58
+ def list(context_id: nil, status: nil, status_timestamp_after: nil,
59
+ page_size: DEFAULT_PAGE_SIZE, page_token: nil)
60
+ validate_page_size!(page_size)
61
+ validate_state!(status) if status
62
+ after = normalize_time(status_timestamp_after) if status_timestamp_after
63
+ fingerprint = query_fingerprint(context_id:, status:, status_timestamp_after: after, page_size:)
64
+
65
+ @mutex.synchronize do
66
+ rows, offset = page_rows(page_token, fingerprint) do
67
+ filtered_rows(context_id:, status:, status_timestamp_after: after)
68
+ end
69
+
70
+ next_offset = offset + page_size
71
+ next_token = ""
72
+ if next_offset < rows.length
73
+ next_token = SecureRandom.uuid
74
+ @pages[next_token] = { rows: rows, offset: next_offset, fingerprint: fingerprint }
75
+ end
76
+
77
+ {
78
+ tasks: copy(rows.slice(offset, page_size) || []),
79
+ total_size: rows.length,
80
+ page_size: page_size,
81
+ next_page_token: next_token
82
+ }
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def find!(task_id)
89
+ @tasks[task_id] || raise(TaskNotFoundError, task_id)
90
+ end
91
+
92
+ def terminal?(task)
93
+ TERMINAL_STATES.include?(task.dig(:status, :state))
94
+ end
95
+
96
+ def validate_state!(state)
97
+ return if STATES.include?(state)
98
+
99
+ raise InvalidTaskStateError, "Unknown task state: #{state.inspect}"
100
+ end
101
+
102
+ def validate_page_size!(page_size)
103
+ return if page_size.is_a?(Integer) && (1..MAX_PAGE_SIZE).cover?(page_size)
104
+
105
+ raise InvalidTaskQueryError, "page_size must be an integer from 1 to #{MAX_PAGE_SIZE}"
106
+ end
107
+
108
+ def normalize_time(value)
109
+ return value.utc if value.respond_to?(:utc) && !value.is_a?(String)
110
+
111
+ Time.iso8601(value.to_s).utc
112
+ rescue ArgumentError
113
+ raise InvalidTaskQueryError, "status_timestamp_after must be an ISO 8601 timestamp"
114
+ end
115
+
116
+ def status_hash(state, timestamp, message)
117
+ status = { state: state, timestamp: normalize_time(timestamp).iso8601(6) }
118
+ status[:message] = message unless message.equal?(UNSET)
119
+ status
120
+ end
121
+
122
+ def page_rows(page_token, fingerprint)
123
+ if page_token && !page_token.empty?
124
+ cursor = @pages[page_token]
125
+ unless cursor && cursor[:fingerprint] == fingerprint
126
+ raise InvalidTaskQueryError, "Invalid page_token or changed query"
127
+ end
128
+
129
+ [cursor[:rows], cursor[:offset]]
130
+ else
131
+ [copy(yield), 0]
132
+ end
133
+ end
134
+
135
+ def filtered_rows(context_id:, status:, status_timestamp_after:)
136
+ @tasks.values
137
+ .select do |task|
138
+ (!context_id || task[:context_id] == context_id) &&
139
+ (!status || task.dig(:status, :state) == status) &&
140
+ (!status_timestamp_after || task_time(task) >= status_timestamp_after)
141
+ end
142
+ .sort_by { |task| [task_time(task), task.fetch(:id)] }
143
+ .reverse
144
+ end
145
+
146
+ def task_time(task)
147
+ Time.iso8601(task.dig(:status, :timestamp))
148
+ end
149
+
150
+ def query_fingerprint(context_id:, status:, status_timestamp_after:, page_size:)
151
+ normalized = [context_id, status, status_timestamp_after&.iso8601(6), page_size]
152
+ Digest::SHA256.hexdigest(Marshal.dump(normalized))
153
+ end
154
+
155
+ def copy(value)
156
+ Marshal.load(Marshal.dump(value))
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module Task
6
+ class ResultMapper
7
+ FAILURE_MESSAGE = "Task execution failed"
8
+ REJECTION_MESSAGE = "Task rejected"
9
+
10
+ def initialize(artifact_mapper: ArtifactMapper.new)
11
+ @artifact_mapper = artifact_mapper
12
+ end
13
+
14
+ def completed(value)
15
+ artifact = @artifact_mapper.call(value)
16
+ outcome = { state: :completed }
17
+ outcome[:artifacts] = [artifact] if artifact
18
+ outcome
19
+ end
20
+
21
+ def rejected(error)
22
+ message = error.message.to_s.empty? ? REJECTION_MESSAGE : error.message.to_s
23
+ { state: :rejected, message: message }
24
+ end
25
+
26
+ def failed(_error)
27
+ { state: :failed, message: FAILURE_MESSAGE }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module Task
6
+ class Store
7
+ def save(_task)
8
+ raise NotImplementedError, "Task stores must implement #save"
9
+ end
10
+
11
+ def find(_task_id)
12
+ raise NotImplementedError, "Task stores must implement #find"
13
+ end
14
+
15
+ def transition(_task_id, state:, **_attributes)
16
+ raise NotImplementedError, "Task stores must implement #transition"
17
+ end
18
+
19
+ def cancel(_task_id, **_attributes)
20
+ raise NotImplementedError, "Task stores must implement #cancel"
21
+ end
22
+
23
+ def list(**_filters)
24
+ raise NotImplementedError, "Task stores must implement #list"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module Task
6
+ STATES = %i[submitted working completed failed rejected canceled].freeze
7
+ TERMINAL_STATES = %i[completed failed rejected canceled].freeze
8
+ end
9
+ end
10
+ end
11
+
12
+ require_relative "task/store"
13
+ require_relative "task/memory_store"
14
+ require_relative "task/artifact_mapper"
15
+ require_relative "task/result_mapper"
16
+ require_relative "task/lifecycle"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/a2a-rails.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "a2a/rails/version"
4
+ require_relative "a2a/rails/errors"
5
+ require_relative "a2a/rails/configuration"
6
+ require_relative "a2a/rails/skill"
7
+ require_relative "a2a/rails/agent"
8
+ require_relative "a2a/rails/dispatcher"
9
+ require_relative "a2a/rails/agent_card/validator"
10
+ require_relative "a2a/rails/agent_card/builder"
11
+ require_relative "a2a/rails/task"
12
+ require_relative "a2a/rails/protocol/adapter"
13
+ require_relative "a2a/rails/protocol/task_mapper"
14
+ require_relative "a2a/rails/protocol/request_handler"
15
+ require_relative "a2a/rails/protocol/agent2agent_adapter"
16
+ require_relative "a2a/rails/runtime"
17
+ require_relative "a2a/rails/engine"
18
+
19
+ module A2A
20
+ module Rails
21
+ class << self
22
+ def configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ def configure
27
+ yield configuration
28
+ configuration
29
+ end
30
+
31
+ def runtime
32
+ @runtime ||= Runtime.new
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module A2A
6
+ module Rails
7
+ class AgentGenerator < ::Rails::Generators::NamedBase
8
+ namespace "a2a:rails:agent"
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def create_agent
12
+ template "agent.rb.tt", File.join("app/agents", class_path, "#{file_name}_agent.rb")
13
+ end
14
+
15
+ def agent_display_name
16
+ file_name.split("_").map(&:capitalize).join(" ")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module A2A
6
+ module Rails
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ namespace "a2a:rails:install"
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def create_initializer
12
+ template "initializer.rb.tt", "config/initializers/a2a_rails.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ class <%= class_name %>Agent < A2A::Rails::Agent
2
+ name "<%= agent_display_name %> Agent"
3
+ description "TODO"
4
+ version "1.0"
5
+
6
+ # Add at least one skill.
7
+ end
@@ -0,0 +1,4 @@
1
+ A2A::Rails.configure do |config|
2
+ config.agent = "YourAgent"
3
+ config.public_base_url = ENV["A2A_PUBLIC_BASE_URL"]
4
+ end
metadata ADDED
@@ -0,0 +1,192 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: a2a-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - cuichangquan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-10-06 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: agent2agent
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 2.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 2.0.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '8.2'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '8.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '8.2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: json
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '3'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '3'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rack
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.0'
67
+ - - "<"
68
+ - !ruby/object:Gem::Version
69
+ version: '4'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.0'
77
+ - - "<"
78
+ - !ruby/object:Gem::Version
79
+ version: '4'
80
+ - !ruby/object:Gem::Dependency
81
+ name: railties
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '8.0'
87
+ - - "<"
88
+ - !ruby/object:Gem::Version
89
+ version: '8.2'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '8.0'
97
+ - - "<"
98
+ - !ruby/object:Gem::Version
99
+ version: '8.2'
100
+ - !ruby/object:Gem::Dependency
101
+ name: minitest
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: '5.25'
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - "~>"
112
+ - !ruby/object:Gem::Version
113
+ version: '5.25'
114
+ - !ruby/object:Gem::Dependency
115
+ name: rake
116
+ requirement: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - "~>"
119
+ - !ruby/object:Gem::Version
120
+ version: '13.2'
121
+ type: :development
122
+ prerelease: false
123
+ version_requirements: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - "~>"
126
+ - !ruby/object:Gem::Version
127
+ version: '13.2'
128
+ description: Expose Rails applications as A2A agents while keeping protocol SDK details
129
+ behind an internal adapter boundary.
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - CHANGELOG.md
135
+ - LICENSE
136
+ - README.md
137
+ - app/controllers/a2a/rails/agent_cards_controller.rb
138
+ - app/controllers/a2a/rails/application_controller.rb
139
+ - app/controllers/a2a/rails/requests_controller.rb
140
+ - config/routes.rb
141
+ - lib/a2a-rails.rb
142
+ - lib/a2a/rails/agent.rb
143
+ - lib/a2a/rails/agent_card/builder.rb
144
+ - lib/a2a/rails/agent_card/validator.rb
145
+ - lib/a2a/rails/configuration.rb
146
+ - lib/a2a/rails/dispatcher.rb
147
+ - lib/a2a/rails/engine.rb
148
+ - lib/a2a/rails/errors.rb
149
+ - lib/a2a/rails/protocol/adapter.rb
150
+ - lib/a2a/rails/protocol/agent2agent_adapter.rb
151
+ - lib/a2a/rails/protocol/request_handler.rb
152
+ - lib/a2a/rails/protocol/task_mapper.rb
153
+ - lib/a2a/rails/runtime.rb
154
+ - lib/a2a/rails/skill.rb
155
+ - lib/a2a/rails/task.rb
156
+ - lib/a2a/rails/task/artifact_mapper.rb
157
+ - lib/a2a/rails/task/lifecycle.rb
158
+ - lib/a2a/rails/task/memory_store.rb
159
+ - lib/a2a/rails/task/result_mapper.rb
160
+ - lib/a2a/rails/task/store.rb
161
+ - lib/a2a/rails/version.rb
162
+ - lib/generators/a2a/rails/agent_generator.rb
163
+ - lib/generators/a2a/rails/install_generator.rb
164
+ - lib/generators/a2a/rails/templates/agent.rb.tt
165
+ - lib/generators/a2a/rails/templates/initializer.rb.tt
166
+ homepage: https://github.com/cuichangquan/a2a-rails
167
+ licenses:
168
+ - MIT
169
+ metadata:
170
+ source_code_uri: https://github.com/cuichangquan/a2a-rails
171
+ changelog_uri: https://github.com/cuichangquan/a2a-rails/blob/main/CHANGELOG.md
172
+ documentation_uri: https://github.com/cuichangquan/a2a-rails#readme
173
+ bug_tracker_uri: https://github.com/cuichangquan/a2a-rails/issues
174
+ rubygems_mfa_required: 'true'
175
+ rdoc_options: []
176
+ require_paths:
177
+ - lib
178
+ required_ruby_version: !ruby/object:Gem::Requirement
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: '3.3'
183
+ required_rubygems_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ requirements: []
189
+ rubygems_version: 3.6.4
190
+ specification_version: 4
191
+ summary: Rails-native integration for exposing Rails applications as A2A agents
192
+ test_files: []