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
@@ -12,6 +12,14 @@ module A2A
12
12
  class Agent2AgentAdapter < Adapter
13
13
  PROTOCOL_VERSION = "1.0"
14
14
  SUPPORTED_OPERATIONS = %w[SendMessage GetTask ListTasks CancelTask].freeze
15
+ # A2A v1.0 distinguishes unsupported push notifications (-32003)
16
+ # from other unsupported operations (-32004) on the JSON-RPC wire.
17
+ PUSH_NOTIFICATION_OPERATIONS = %w[
18
+ CreateTaskPushNotificationConfig
19
+ GetTaskPushNotificationConfig
20
+ ListTaskPushNotificationConfigs
21
+ DeleteTaskPushNotificationConfig
22
+ ].freeze
15
23
  RESPONSE_SCHEMAS = {
16
24
  "SendMessage" => "Send Message Response",
17
25
  "GetTask" => "Task",
@@ -74,6 +82,9 @@ module A2A
74
82
  validate_version!(env)
75
83
 
76
84
  operation = env.fetch("a2a.operation")
85
+ if PUSH_NOTIFICATION_OPERATIONS.include?(operation)
86
+ raise A2A::PushNotificationNotSupportedError.new
87
+ end
77
88
  raise A2A::UnsupportedOperationError.new unless SUPPORTED_OPERATIONS.include?(operation)
78
89
 
79
90
  request = env.fetch("a2a.request")
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module A2A
4
6
  module Rails
5
7
  module Protocol
6
8
  class RequestHandler
7
- def initialize(dispatcher:, lifecycle:, task_mapper: TaskMapper.new)
9
+ def initialize(dispatcher:, lifecycle:, task_mapper: TaskMapper.new, task_job: TaskExecutionJob)
8
10
  @dispatcher = dispatcher
9
11
  @lifecycle = lifecycle
10
12
  @task_mapper = task_mapper
13
+ @task_job = task_job
11
14
  end
12
15
 
13
16
  def call(operation:, params:)
@@ -34,22 +37,96 @@ module A2A
34
37
  history_length = history_length(params)
35
38
  message, context_id = normalize_message(params)
36
39
 
40
+ # Direct Message is an explicit host-side choice, never inferred
41
+ # from an untrusted client flag or forced by the protocol adapter.
42
+ if safe_response_mode(message) == :message
43
+ return send_direct_message(message, context_id)
44
+ end
45
+
37
46
  task = @lifecycle.create(message: message, context_id: context_id)
38
- @lifecycle.start(task.fetch(:id))
39
- context = { task_id: task.fetch(:id), context_id: task.fetch(:context_id) }
47
+ context = execution_context(task)
40
48
 
41
- task = begin
42
- result = @dispatcher.call(message: message, context: context)
43
- @lifecycle.complete(task.fetch(:id), result)
49
+ plan = begin
50
+ @dispatcher.plan(message: message, context: context)
44
51
  rescue RejectedTask => error
45
- @lifecycle.reject(task.fetch(:id), error)
52
+ task = @lifecycle.reject(task.fetch(:id), error)
53
+ nil
46
54
  rescue StandardError => error
47
- @lifecycle.fail(task.fetch(:id), error)
55
+ task = @lifecycle.fail(task.fetch(:id), error)
56
+ nil
57
+ end
58
+
59
+ if plan&.async?
60
+ task = enqueue_async_task(task, plan)
61
+ elsif plan
62
+ @lifecycle.start(task.fetch(:id))
63
+ task = begin
64
+ result = @dispatcher.execute(plan: plan, message: message, context: context)
65
+ @lifecycle.complete(task.fetch(:id), result)
66
+ rescue RejectedTask => error
67
+ @lifecycle.reject(task.fetch(:id), error)
68
+ rescue StandardError => error
69
+ @lifecycle.fail(task.fetch(:id), error)
70
+ end
48
71
  end
49
72
 
50
73
  { "task" => @task_mapper.dump(task, history_length: history_length, include_artifacts: true) }
51
74
  end
52
75
 
76
+ def enqueue_async_task(task, plan)
77
+ arguments = TaskExecutionArguments.new(
78
+ task_id: task.fetch(:id),
79
+ principal_id: @lifecycle.principal_id,
80
+ agent_class_name: plan.agent_class_name,
81
+ skill_id: plan.skill_id
82
+ )
83
+ job = @task_job.perform_later(**arguments.to_h)
84
+ enqueue_error = job.respond_to?(:enqueue_error) ? job.enqueue_error : nil
85
+ successfully_enqueued = if job && job.respond_to?(:successfully_enqueued?)
86
+ job.successfully_enqueued?
87
+ else
88
+ !!job
89
+ end
90
+ return task if successfully_enqueued && enqueue_error.nil?
91
+
92
+ @lifecycle.fail(
93
+ task.fetch(:id),
94
+ enqueue_error || ConfigurationError.new("Task execution job was not enqueued")
95
+ )
96
+ rescue StandardError => error
97
+ @lifecycle.fail(task.fetch(:id), error)
98
+ end
99
+
100
+ def execution_context(task)
101
+ {
102
+ task_id: task.fetch(:id),
103
+ context_id: task.fetch(:context_id),
104
+ principal_id: @lifecycle.principal_id,
105
+ idempotency_key: task.fetch(:id)
106
+ }
107
+ end
108
+
109
+ def safe_response_mode(message)
110
+ @dispatcher.response_mode(message: message)
111
+ rescue StandardError
112
+ # The selector is host application code and may raise with secrets.
113
+ # No Task exists yet to record the failure as a Task status.
114
+ raise A2A::InvalidAgentResponseError.new
115
+ end
116
+
117
+ def send_direct_message(message, context_id)
118
+ context_id = SecureRandom.uuid if context_id.nil? || context_id.empty?
119
+ result = @dispatcher.call(
120
+ message: message,
121
+ context: { task_id: nil, context_id: context_id }
122
+ )
123
+ { "message" => @task_mapper.dump_direct_message(result, context_id: context_id) }
124
+ rescue StandardError
125
+ # No Task exists to record a FAILED status. Never send application
126
+ # exception details or unsupported result values to A2A callers.
127
+ raise A2A::InvalidAgentResponseError.new
128
+ end
129
+
53
130
  def get_task(params)
54
131
  history_length = history_length(params)
55
132
  task = @lifecycle.find(required_id(params))
@@ -58,6 +135,7 @@ module A2A
58
135
 
59
136
  def list_tasks(params)
60
137
  history_length = history_length(params)
138
+ validate_list_filters!(params)
61
139
  filters = {}
62
140
  filters[:context_id] = params["contextId"] if params.key?("contextId")
63
141
  filters[:status] = @task_mapper.internal_state(params["status"]) if params.key?("status")
@@ -103,8 +181,21 @@ module A2A
103
181
  raise InvalidRequestError, "nonempty parts are required"
104
182
  end
105
183
 
106
- if raw["taskId"].is_a?(String) && !raw["taskId"].empty?
107
- @lifecycle.find(raw["taskId"])
184
+ task_id = raw["taskId"]
185
+ if !task_id.nil? && !task_id.is_a?(String)
186
+ raise InvalidRequestError, "taskId must be a String"
187
+ end
188
+ context_id = raw["contextId"]
189
+ if !context_id.nil? && !context_id.is_a?(String)
190
+ raise InvalidRequestError, "contextId must be a String"
191
+ end
192
+ metadata = raw["metadata"]
193
+ unless metadata.nil? || metadata.is_a?(Hash)
194
+ raise InvalidRequestError, "message metadata must be an object"
195
+ end
196
+
197
+ if task_id.is_a?(String) && !task_id.empty?
198
+ @lifecycle.find(task_id)
108
199
  raise TaskContinuationNotSupportedError, "Task continuation is not supported in v0.1"
109
200
  end
110
201
 
@@ -113,25 +204,45 @@ module A2A
113
204
  message_id: message_id,
114
205
  role: :user,
115
206
  parts: normalized_parts,
116
- metadata: copy(raw["metadata"] || {})
207
+ metadata: copy(metadata || {})
117
208
  }
118
209
 
119
- [message, raw["contextId"]]
210
+ [message, context_id]
120
211
  end
121
212
 
122
213
  def normalize_text_part(part)
123
214
  unless part.is_a?(Hash) && part["text"].is_a?(String) && (part.keys & %w[data raw url]).empty?
124
215
  raise ContentTypeNotSupportedError, "Only text Parts are supported in v0.1"
125
216
  end
217
+ media_type = part["mediaType"]
218
+ unless media_type.nil? || media_type.is_a?(String)
219
+ raise InvalidRequestError, "mediaType must be a String"
220
+ end
221
+ metadata = part["metadata"]
222
+ unless metadata.nil? || metadata.is_a?(Hash)
223
+ raise InvalidRequestError, "part metadata must be an object"
224
+ end
126
225
 
127
226
  normalized = {
128
227
  text: part["text"],
129
228
  media_type: part["mediaType"].to_s.empty? ? "text/plain" : part["mediaType"]
130
229
  }
131
- normalized[:metadata] = copy(part["metadata"]) if part.key?("metadata")
230
+ normalized[:metadata] = copy(metadata) if metadata
132
231
  normalized
133
232
  end
134
233
 
234
+ def validate_list_filters!(params)
235
+ if params.key?("contextId") && !params["contextId"].nil? && !params["contextId"].is_a?(String)
236
+ raise InvalidRequestError, "contextId must be a String"
237
+ end
238
+ if params.key?("pageToken") && !params["pageToken"].is_a?(String)
239
+ raise InvalidRequestError, "pageToken must be a String"
240
+ end
241
+ if params.key?("includeArtifacts") && ![true, false].include?(params["includeArtifacts"])
242
+ raise InvalidRequestError, "includeArtifacts must be boolean"
243
+ end
244
+ end
245
+
135
246
  def required_id(params)
136
247
  id = params["id"]
137
248
  unless id.is_a?(String) && !id.empty?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module A2A
4
6
  module Rails
5
7
  module Protocol
@@ -33,6 +35,19 @@ module A2A
33
35
  result
34
36
  end
35
37
 
38
+ def dump_direct_message(value, context_id:)
39
+ artifact = Task::ArtifactMapper.new.call(value)
40
+ unless artifact
41
+ raise ArtifactMappingError, "Direct Message requires a non-nil Handler result"
42
+ end
43
+
44
+ dump_message(
45
+ message_id: SecureRandom.uuid,
46
+ role: :agent,
47
+ parts: artifact.fetch(:parts)
48
+ ).merge("contextId" => context_id)
49
+ end
50
+
36
51
  def internal_state(value)
37
52
  return nil if value.nil?
38
53
 
@@ -86,6 +101,12 @@ module A2A
86
101
  result = {}
87
102
  result["text"] = part[:text] if part.key?(:text)
88
103
  result["data"] = copy(part[:data]) if part.key?(:data)
104
+ result["raw"] = part[:raw] if part.key?(:raw)
105
+ result["url"] = part[:url] if part.key?(:url)
106
+ result["filename"] = part[:filename] if part.key?(:filename)
107
+ if part.key?(:raw) || part.key?(:url)
108
+ result["mediaType"] = part[:media_type] if part.key?(:media_type)
109
+ end
89
110
  result["metadata"] = copy(part[:metadata]) if part.key?(:metadata)
90
111
  result
91
112
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "json"
5
+
6
+ module A2A
7
+ module Rails
8
+ # Enforces a bounded JSON-RPC request body before application callbacks
9
+ # or the upstream SDK can read/parse arbitrary request data.
10
+ module RequestGuard
11
+ DEFAULT_MAX_BYTES = 1_048_576
12
+ MAX_CONFIGURABLE_BYTES = 16 * 1_048_576
13
+
14
+ class PayloadTooLarge < StandardError; end
15
+ class UnsupportedMediaType < StandardError; end
16
+ class InvalidBody < StandardError; end
17
+ class InvalidConfiguration < StandardError; end
18
+
19
+ module_function
20
+
21
+ def enforce!(env:, max_bytes:)
22
+ unless max_bytes.is_a?(Integer) && max_bytes.between?(1, MAX_CONFIGURABLE_BYTES)
23
+ raise InvalidConfiguration, "max_request_bytes must be between 1 and #{MAX_CONFIGURABLE_BYTES}"
24
+ end
25
+
26
+ media_type = env["CONTENT_TYPE"].to_s.split(";", 2).first.to_s.strip.downcase
27
+ raise UnsupportedMediaType unless media_type == "application/json"
28
+
29
+ encoding = env["HTTP_CONTENT_ENCODING"].to_s.strip.downcase
30
+ raise UnsupportedMediaType unless encoding.empty? || encoding == "identity"
31
+
32
+ length = env["CONTENT_LENGTH"]
33
+ if length && (!length.is_a?(String) || !length.match?(/\A[0-9]+\z/))
34
+ raise InvalidBody
35
+ end
36
+ raise PayloadTooLarge if length && length.to_i > max_bytes
37
+
38
+ input = env["rack.input"]
39
+ raise InvalidBody unless input.respond_to?(:read)
40
+
41
+ # Read one byte beyond the permitted maximum to catch requests without
42
+ # Content-Length, chunked bodies, and falsely understated lengths.
43
+ body = input.read(max_bytes + 1)
44
+ raise InvalidBody unless body.is_a?(String)
45
+ raise PayloadTooLarge if body.bytesize > max_bytes
46
+ raise InvalidBody if length && length.to_i != body.bytesize
47
+
48
+ validate_sdk_sensitive_shapes!(body)
49
+
50
+ # The SDK receives the *bounded* copy, never the original input stream.
51
+ env["rack.input"] = StringIO.new(body)
52
+ env["CONTENT_LENGTH"] = body.bytesize.to_s
53
+ true
54
+ end
55
+
56
+ # The upstream agent2agent 2.0.0 ExtractMessage middleware may iterate
57
+ # SendMessage Parts before our RequestHandler validates them, and can
58
+ # raise NoMethodError on a null/non-object Part. Reject those shapes at
59
+ # the bounded HTTP boundary to ensure malformed requests fail safely.
60
+ def validate_sdk_sensitive_shapes!(body)
61
+ payload = JSON.parse(body)
62
+ return unless payload.is_a?(Hash) && payload["method"] == "SendMessage"
63
+
64
+ params = payload["params"]
65
+ message = params["message"] if params.is_a?(Hash)
66
+ parts = message["parts"] if message.is_a?(Hash)
67
+
68
+ unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(Hash) }
69
+ raise InvalidBody
70
+ end
71
+ rescue JSON::ParserError, JSON::NestingError
72
+ raise InvalidBody
73
+ end
74
+ end
75
+ end
76
+ end
@@ -3,7 +3,7 @@
3
3
  module A2A
4
4
  module Rails
5
5
  class Runtime
6
- def initialize(store: Task::MemoryStore.new)
6
+ def initialize(store: nil)
7
7
  @store = store
8
8
  end
9
9
 
@@ -14,21 +14,26 @@ module A2A
14
14
  AgentCard::Builder.new(
15
15
  agent: agent,
16
16
  public_base_url: configuration.normalized_public_base_url,
17
- request_base_url: request_base_url
17
+ request_base_url: request_base_url,
18
+ security: AgentCard::Security.new(configuration: configuration).fields
18
19
  ).call
19
20
  end
20
21
 
21
- def call(env:, request_base_url:)
22
+ def call(env:, request_base_url:, principal_id: nil)
22
23
  configuration = A2A::Rails.configuration
23
24
  agent = configuration.resolve_agent
24
25
  card = AgentCard::Builder.new(
25
26
  agent: agent,
26
27
  public_base_url: configuration.normalized_public_base_url,
27
- request_base_url: request_base_url
28
+ request_base_url: request_base_url,
29
+ security: AgentCard::Security.new(configuration: configuration).fields
28
30
  ).call
29
- lifecycle = Task::Lifecycle.new(store: @store, logger: configuration.logger)
31
+ # The controller has already verified this Rack-scoped principal.
32
+ # Never read identity from A2A JSON-RPC parameters or metadata.
33
+ principal_id ||= env[Authentication::PRINCIPAL_ENV_KEY]
34
+ lifecycle = Task::Lifecycle.new(store: task_store, logger: configuration.logger, principal_id: principal_id)
30
35
  request_handler = Protocol::RequestHandler.new(
31
- dispatcher: Dispatcher.new(agent: agent),
36
+ dispatcher: Dispatcher.new(agent: agent, configuration: configuration),
32
37
  lifecycle: lifecycle
33
38
  )
34
39
  adapter = Protocol::Agent2AgentAdapter.new(
@@ -38,6 +43,10 @@ module A2A
38
43
 
39
44
  adapter.call(env)
40
45
  end
46
+
47
+ def task_store
48
+ @store ||= A2A::Rails.configuration.resolve_task_store
49
+ end
41
50
  end
42
51
  end
43
52
  end
@@ -3,9 +3,9 @@
3
3
  module A2A
4
4
  module Rails
5
5
  class Skill
6
- attr_reader :id, :name, :description, :tags, :handler, :examples, :input_modes, :output_modes
6
+ attr_reader :id, :name, :description, :tags, :handler, :examples, :input_modes, :output_modes, :execution_mode
7
7
 
8
- def initialize(id:, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil)
8
+ def initialize(id:, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil, execution_mode: nil)
9
9
  @id = normalize_id(id)
10
10
  @name = normalize_text(name || humanize_id(@id))
11
11
  @description = normalize_optional_text(description)
@@ -14,6 +14,7 @@ module A2A
14
14
  @examples = normalize_optional_collection(examples)
15
15
  @input_modes = normalize_optional_collection(input_modes)
16
16
  @output_modes = normalize_optional_collection(output_modes)
17
+ @execution_mode = execution_mode
17
18
  freeze
18
19
  end
19
20
 
@@ -34,6 +35,10 @@ module A2A
34
35
  validate_optional_strings!(:input_modes, @input_modes, allow_empty: false)
35
36
  validate_optional_strings!(:output_modes, @output_modes, allow_empty: false)
36
37
 
38
+ unless @execution_mode.nil? || Configuration::TASK_EXECUTION_MODES.include?(@execution_mode)
39
+ raise ConfigurationError, "Skill #{@id.inspect} execution_mode must be :sync or :async"
40
+ end
41
+
37
42
  unless @handler.respond_to?(:call)
38
43
  raise InvalidHandlerError, "Handler #{handler_label} must respond to .call"
39
44
  end