a2a-rails 0.1.0 → 0.2.0.rc2

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +261 -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/errors.rb +3 -0
  13. data/lib/a2a/rails/execution_plan.rb +40 -0
  14. data/lib/a2a/rails/file_artifact.rb +77 -0
  15. data/lib/a2a/rails/protocol/agent2agent_adapter.rb +11 -0
  16. data/lib/a2a/rails/protocol/request_handler.rb +124 -13
  17. data/lib/a2a/rails/protocol/task_mapper.rb +21 -0
  18. data/lib/a2a/rails/request_guard.rb +76 -0
  19. data/lib/a2a/rails/runtime.rb +15 -6
  20. data/lib/a2a/rails/skill.rb +7 -2
  21. data/lib/a2a/rails/task/active_record_store.rb +451 -0
  22. data/lib/a2a/rails/task/artifact_mapper.rb +2 -0
  23. data/lib/a2a/rails/task/lifecycle.rb +18 -8
  24. data/lib/a2a/rails/task/memory_store.rb +39 -15
  25. data/lib/a2a/rails/task/store.rb +16 -4
  26. data/lib/a2a/rails/task_execution_arguments.rb +78 -0
  27. data/lib/a2a/rails/task_execution_job.rb +79 -0
  28. data/lib/a2a/rails/version.rb +1 -1
  29. data/lib/a2a-rails.rb +7 -0
  30. data/lib/generators/a2a/rails/task_store_generator.rb +46 -0
  31. data/lib/generators/a2a/rails/templates/create_a2a_rails_tasks.rb.tt +31 -0
  32. data/lib/generators/a2a/rails/templates/initializer.rb.tt +4 -0
  33. data/lib/tasks/a2a_rails_tasks.rake +54 -0
  34. metadata +94 -3
@@ -4,6 +4,10 @@ module A2A
4
4
  module Rails
5
5
  class RequestsController < ApplicationController
6
6
  def create
7
+ response.set_header("Cache-Control", "no-store")
8
+ return unless validate_a2a_request_body
9
+ return unless authenticate_a2a_request
10
+
7
11
  status, headers, body = A2A::Rails.runtime.call(
8
12
  env: request.env,
9
13
  request_base_url: request.base_url
@@ -13,6 +17,55 @@ module A2A
13
17
  headers.each { |name, value| response.set_header(name, value) }
14
18
  self.response_body = body
15
19
  end
20
+
21
+ private
22
+
23
+ def validate_a2a_request_body
24
+ RequestGuard.enforce!(
25
+ env: request.env,
26
+ max_bytes: A2A::Rails.configuration.max_request_bytes
27
+ )
28
+ true
29
+ rescue RequestGuard::PayloadTooLarge
30
+ render json: { error: "Payload too large" }, status: 413
31
+ false
32
+ rescue RequestGuard::UnsupportedMediaType
33
+ # This is an HTTP boundary rejection before JSON-RPC parsing (and
34
+ # before authentication). Respond with no JSON body: a JSON string
35
+ # under "error" is not a valid JSON-RPC Error object and confuses
36
+ # protocol clients that parse an HTTP 415 response as JSON-RPC.
37
+ head :unsupported_media_type
38
+ false
39
+ rescue RequestGuard::InvalidBody
40
+ render json: { error: "Invalid request body" }, status: 400
41
+ false
42
+ rescue RequestGuard::InvalidConfiguration
43
+ A2A::Rails.configuration.logger&.error("[a2a-rails] invalid request size configuration")
44
+ render json: { error: "Request validation unavailable" }, status: 500
45
+ false
46
+ end
47
+
48
+ def authenticate_a2a_request
49
+ Authentication.authenticate!(
50
+ request: request,
51
+ configuration: A2A::Rails.configuration,
52
+ rails_environment: ::Rails.env
53
+ )
54
+ true
55
+ rescue Authentication::Unauthorized
56
+ response.set_header("WWW-Authenticate", A2A::Rails.configuration.authentication_challenge)
57
+ render json: { error: "Unauthorized" }, status: :unauthorized
58
+ false
59
+ rescue Authentication::Forbidden
60
+ render json: { error: "Forbidden" }, status: :forbidden
61
+ false
62
+ rescue StandardError => error
63
+ # The exception message may contain credentials supplied by host code.
64
+ # Log the class only; never echo verifier failures into the response.
65
+ A2A::Rails.configuration.logger&.error("[a2a-rails] authentication unavailable: #{error.class}")
66
+ render json: { error: "Authentication unavailable" }, status: :internal_server_error
67
+ false
68
+ end
16
69
  end
17
70
  end
18
71
  end
@@ -34,7 +34,21 @@ module A2A
34
34
  @a2a_router = value
35
35
  end
36
36
 
37
- def skill(id, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil)
37
+ def execution_mode(value = UNSET)
38
+ return @a2a_execution_mode if value.equal?(UNSET)
39
+
40
+ @a2a_execution_mode = value
41
+ end
42
+
43
+ # Task replies remain the default. A host may opt into direct Message
44
+ # replies or provide a callable to choose the mode per request.
45
+ def response_mode(value = UNSET)
46
+ return @a2a_response_mode || :task if value.equal?(UNSET)
47
+
48
+ @a2a_response_mode = value
49
+ end
50
+
51
+ def skill(id, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil, execution_mode: nil)
38
52
  definition = Skill.new(
39
53
  id: id,
40
54
  name: name,
@@ -43,7 +57,8 @@ module A2A
43
57
  handler: handler,
44
58
  examples: examples,
45
59
  input_modes: input_modes,
46
- output_modes: output_modes
60
+ output_modes: output_modes,
61
+ execution_mode: execution_mode
47
62
  )
48
63
  skill_definitions << definition
49
64
  definition
@@ -57,6 +72,8 @@ module A2A
57
72
  validate_metadata!
58
73
  validate_skills!
59
74
  validate_router!
75
+ validate_response_mode!
76
+ validate_execution_mode!
60
77
  self
61
78
  end
62
79
 
@@ -91,6 +108,20 @@ module A2A
91
108
  skill_definitions.each(&:validate!)
92
109
  end
93
110
 
111
+ def validate_response_mode!
112
+ mode = response_mode
113
+ return if %i[task message].include?(mode) || mode.respond_to?(:call)
114
+
115
+ raise ConfigurationError, "response_mode must be :task, :message, or a callable"
116
+ end
117
+
118
+ def validate_execution_mode!
119
+ mode = execution_mode
120
+ return if mode.nil? || Configuration::TASK_EXECUTION_MODES.include?(mode)
121
+
122
+ raise ConfigurationError, "execution_mode must be :sync or :async"
123
+ end
124
+
94
125
  def validate_router!
95
126
  if skill_definitions.length > 1 && @a2a_router.nil?
96
127
  raise ConfigurationError, "#{self} defines multiple skills but no Router is configured"
@@ -9,10 +9,11 @@ module A2A
9
9
  DEFAULT_INPUT_MODES = ["text/plain"].freeze
10
10
  DEFAULT_OUTPUT_MODES = ["text/plain"].freeze
11
11
 
12
- def initialize(agent:, public_base_url: nil, request_base_url: nil, validator: Validator.new)
12
+ def initialize(agent:, public_base_url: nil, request_base_url: nil, security: {}, validator: Validator.new)
13
13
  @agent = agent
14
14
  @public_base_url = public_base_url
15
15
  @request_base_url = request_base_url
16
+ @security = security
16
17
  @validator = validator
17
18
  end
18
19
 
@@ -40,6 +41,7 @@ module A2A
40
41
  "skills" => @agent.skills.map { |skill| skill_hash(skill) }
41
42
  }
42
43
 
44
+ card.merge!(@security) unless @security.empty?
43
45
  @validator.validate!(card)
44
46
  end
45
47
 
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module AgentCard
6
+ # A2A v1.0 Agent Card security metadata is declarative. The host Rails
7
+ # application MUST independently verify the advertised credentials.
8
+ #
9
+ # Step 16-5 supports a verified HTTP Bearer profile only. Do not
10
+ # advertise unimplemented API key, OAuth2, OIDC or mTLS profiles.
11
+ class Security
12
+ def initialize(configuration:, rails_environment: (defined?(::Rails) ? ::Rails.env : "development"))
13
+ @configuration = configuration
14
+ @rails_environment = rails_environment.to_s
15
+ end
16
+
17
+ def fields
18
+ schemes = @configuration.security_schemes
19
+ requirements = @configuration.security_requirements
20
+ authenticator = @configuration.authenticate_request
21
+
22
+ # Preserve the unauthenticated local Quick Start, but never
23
+ # advertise an authentication method that has no verifier.
24
+ if authenticator.nil? && schemes.nil? && requirements.nil?
25
+ # A public Agent Card with no security requirements would falsely
26
+ # imply that production's protected endpoint is open.
27
+ return {} if %w[development test].include?(@rails_environment)
28
+
29
+ raise ConfigurationError, "Agent Card cannot advertise unauthenticated production endpoint"
30
+ end
31
+
32
+ unless authenticator.respond_to?(:call)
33
+ raise ConfigurationError, "Agent Card security needs a configured request authenticator"
34
+ end
35
+
36
+ unless schemes.is_a?(Hash) && !schemes.empty? &&
37
+ requirements.is_a?(Array) && !requirements.empty?
38
+ raise ConfigurationError, "authenticated Agent Cards require security_schemes and security_requirements"
39
+ end
40
+
41
+ normalized_schemes = schemes.each_with_object({}) do |(name, definition), output|
42
+ unless nonempty_string?(name) && definition.is_a?(Hash) && definition.size == 1
43
+ raise ConfigurationError, "invalid Agent Card security scheme"
44
+ end
45
+
46
+ scheme_type, details = definition.first
47
+ case scheme_type
48
+ when "httpAuthSecurityScheme"
49
+ validate_http_auth!(details)
50
+ else
51
+ raise ConfigurationError, "unsupported Agent Card authentication scheme type"
52
+ end
53
+ output[name] = { scheme_type => details.dup }
54
+ end
55
+
56
+ validated_requirements = requirements.map do |requirement|
57
+ unless requirement.is_a?(Hash) && requirement.keys == ["schemes"] &&
58
+ requirement["schemes"].is_a?(Hash) && !requirement["schemes"].empty?
59
+ raise ConfigurationError, "invalid Agent Card security requirement"
60
+ end
61
+
62
+ entries = requirement["schemes"].each_with_object({}) do |(name, scope_list), output|
63
+ unless normalized_schemes.key?(name) &&
64
+ scope_list.is_a?(Hash) && scope_list.keys == ["list"] &&
65
+ scope_list["list"].is_a?(Array) &&
66
+ scope_list["list"].all? { |scope| nonempty_string?(scope) }
67
+ raise ConfigurationError, "Agent Card requirement references unknown or invalid scheme"
68
+ end
69
+ unless scope_list["list"].empty?
70
+ raise ConfigurationError, "Bearer HTTP auth must not advertise OAuth scopes"
71
+ end
72
+ output[name] = { "list" => [] }
73
+ end
74
+ { "schemes" => entries }
75
+ end
76
+
77
+ {
78
+ "securitySchemes" => normalized_schemes,
79
+ "securityRequirements" => validated_requirements
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def nonempty_string?(value)
86
+ value.is_a?(String) && !value.strip.empty? && value.bytesize <= 256 &&
87
+ !value.match?(/[[:cntrl:]]/)
88
+ end
89
+
90
+ def validate_http_auth!(details)
91
+ unless details.is_a?(Hash) &&
92
+ (details.keys - %w[scheme description bearerFormat]).empty? &&
93
+ nonempty_string?(details["scheme"]) &&
94
+ optional_text?(details, "description") &&
95
+ optional_text?(details, "bearerFormat")
96
+ raise ConfigurationError, "invalid HTTP authentication declaration"
97
+ end
98
+ # In v1.0 a Bearer challenge is the only HTTP Auth profile we
99
+ # currently verify together with Rails' authenticate_request hook.
100
+ unless details["scheme"].casecmp?("Bearer")
101
+ raise ConfigurationError, "only Bearer HTTP authentication is supported"
102
+ end
103
+ Authentication.validate_challenge!(@configuration.authentication_challenge)
104
+ unless @configuration.authentication_challenge.match?(/\ABearer(?:\s|\z)/i)
105
+ raise ConfigurationError, "Bearer Agent Card must use a Bearer WWW-Authenticate challenge"
106
+ end
107
+ end
108
+
109
+ def optional_text?(hash, field)
110
+ !hash.key?(field) || nonempty_string?(hash[field])
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ # Gate the A2A HTTP endpoint before the protocol adapter can parse or
6
+ # dispatch a request. Task ownership is a separate security boundary
7
+ # (Step 16-3); authenticating a caller alone does not isolate Tasks.
8
+ module Authentication
9
+ PRINCIPAL_ENV_KEY = "a2a.rails.principal_id"
10
+ MAX_PRINCIPAL_BYTES = 256
11
+
12
+ class Unauthorized < StandardError; end
13
+ class Forbidden < StandardError; end
14
+ class ConfigurationError < StandardError; end
15
+
16
+ module_function
17
+
18
+ def authenticate!(request:, configuration:, rails_environment:)
19
+ # Never accept a pre-existing Rack environment value as a verified
20
+ # principal: only the application's authenticator may populate it.
21
+ request.env.delete(PRINCIPAL_ENV_KEY)
22
+ validate_challenge!(configuration.authentication_challenge)
23
+
24
+ authenticator = configuration.authenticate_request
25
+ if authenticator.nil?
26
+ # Preserve the published local Rails Quick Start in dev/test.
27
+ # Every other Rails environment fails closed without an authenticator.
28
+ return nil if %w[development test].include?(rails_environment.to_s)
29
+
30
+ raise Unauthorized
31
+ end
32
+
33
+ unless authenticator.respond_to?(:call)
34
+ raise ConfigurationError, "authenticate_request must respond to #call"
35
+ end
36
+
37
+ # A host verifier is only available when its advertised security
38
+ # requirements are valid. Never silently publish an open Agent Card.
39
+ AgentCard::Security.new(
40
+ configuration: configuration,
41
+ rails_environment: rails_environment
42
+ ).fields
43
+
44
+ principal_id = authenticator.call(request)
45
+ raise Unauthorized if principal_id.nil? || principal_id == false
46
+
47
+ unless valid_principal_id?(principal_id)
48
+ raise ConfigurationError, "authenticator must return a non-secret principal ID String"
49
+ end
50
+
51
+ # Retain only a stable, non-secret identity for future authorization
52
+ # checks. Never copy Authorization credentials into the Rack env key.
53
+ request.env[PRINCIPAL_ENV_KEY] = principal_id.dup.freeze
54
+ end
55
+
56
+ def validate_challenge!(value)
57
+ if !value.is_a?(String) || value.empty? || value.bytesize > 512 ||
58
+ value.match?(/[\r\n]/)
59
+ raise ConfigurationError, "authentication_challenge must be a safe HTTP challenge String"
60
+ end
61
+ end
62
+
63
+ def valid_principal_id?(value)
64
+ value.is_a?(String) &&
65
+ !value.strip.empty? &&
66
+ value.bytesize <= MAX_PRINCIPAL_BYTES &&
67
+ !value.match?(/[[:cntrl:]]/)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,15 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "uri"
4
5
 
5
6
  module A2A
6
7
  module Rails
7
8
  class Configuration
8
- attr_accessor :agent, :public_base_url
9
+ TASK_STORE_METHODS = %i[save find transition cancel list].freeze
10
+ TASK_EXECUTION_MODES = %i[sync async].freeze
11
+ DEFAULT_TASK_RETENTION_SECONDS = 30 * 24 * 60 * 60
12
+ DEFAULT_TASK_PRUNE_BATCH_SIZE = 1_000
13
+ DEFAULT_MAX_TASKS_PER_OWNER = 10_000
14
+ DEFAULT_MAX_TASK_HISTORY_ENTRIES = 100
15
+ DEFAULT_MAX_TASK_ARTIFACTS = 50
16
+
17
+ attr_accessor :agent, :public_base_url, :authenticate_request, :authentication_challenge,
18
+ :max_request_bytes, :security_schemes, :security_requirements,
19
+ :task_store, :task_page_token_secret, :task_retention,
20
+ :task_prune_batch_size, :max_tasks_per_owner,
21
+ :max_task_history_entries, :max_task_artifacts,
22
+ :task_execution_mode
9
23
 
10
24
  def initialize
11
25
  @agent = nil
12
26
  @public_base_url = nil
27
+ @authenticate_request = nil
28
+ @authentication_challenge = 'Bearer realm="a2a"'
29
+ @security_schemes = nil
30
+ @security_requirements = nil
31
+ @max_request_bytes = RequestGuard::DEFAULT_MAX_BYTES
32
+ @task_store = :memory
33
+ @task_page_token_secret = nil
34
+ @task_retention = DEFAULT_TASK_RETENTION_SECONDS
35
+ @task_prune_batch_size = DEFAULT_TASK_PRUNE_BATCH_SIZE
36
+ @max_tasks_per_owner = DEFAULT_MAX_TASKS_PER_OWNER
37
+ @max_task_history_entries = DEFAULT_MAX_TASK_HISTORY_ENTRIES
38
+ @max_task_artifacts = DEFAULT_MAX_TASK_ARTIFACTS
39
+ @task_execution_mode = :sync
13
40
  @logger_set = false
14
41
  end
15
42
 
@@ -28,9 +55,16 @@ module A2A
28
55
  def validate!
29
56
  validate_agent_name!
30
57
  normalized_public_base_url
58
+ validate_task_execution_mode!(@task_execution_mode, "config.task_execution_mode")
31
59
  self
32
60
  end
33
61
 
62
+ def resolve_task_execution_mode(agent:, skill:)
63
+ mode = skill.execution_mode || agent.execution_mode || @task_execution_mode
64
+ validate_task_execution_mode!(mode, "task execution mode")
65
+ mode
66
+ end
67
+
34
68
  def resolve_agent
35
69
  validate_agent_name!
36
70
 
@@ -52,8 +86,68 @@ module A2A
52
86
  normalize_base_url(value.to_s)
53
87
  end
54
88
 
89
+ def resolve_task_store
90
+ case @task_store
91
+ when nil, :memory
92
+ Task::MemoryStore.new
93
+ when :active_record
94
+ resolve_active_record_store
95
+ else
96
+ validate_task_store!(@task_store)
97
+ end
98
+ end
99
+
55
100
  private
56
101
 
102
+ def validate_task_execution_mode!(mode, label)
103
+ return if TASK_EXECUTION_MODES.include?(mode)
104
+
105
+ raise ConfigurationError, "#{label} must be :sync or :async"
106
+ end
107
+
108
+ def resolve_active_record_store
109
+ require_relative "task/active_record_store"
110
+
111
+ Task::ActiveRecordStore.new(
112
+ cursor_secret: task_cursor_secret,
113
+ retention: @task_retention,
114
+ prune_batch_size: @task_prune_batch_size,
115
+ max_tasks_per_owner: @max_tasks_per_owner,
116
+ max_history_entries: @max_task_history_entries,
117
+ max_artifacts: @max_task_artifacts
118
+ )
119
+ rescue LoadError => error
120
+ raise ConfigurationError,
121
+ "config.task_store = :active_record requires ActiveRecord in the host application (#{error.path})"
122
+ end
123
+
124
+ def task_cursor_secret
125
+ configured = @task_page_token_secret
126
+ if configured
127
+ unless configured.is_a?(String) && configured.bytesize >= 32
128
+ raise ConfigurationError, "config.task_page_token_secret must be at least 32 bytes"
129
+ end
130
+ return configured
131
+ end
132
+
133
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
134
+ secret = ::Rails.application.secret_key_base.to_s
135
+ return Digest::SHA256.digest("a2a-rails/task-page-token/#{secret}") unless secret.empty?
136
+ end
137
+
138
+ raise ConfigurationError,
139
+ "ActiveRecord Task Store needs Rails.application.secret_key_base or config.task_page_token_secret"
140
+ end
141
+
142
+ def validate_task_store!(store)
143
+ missing = TASK_STORE_METHODS.reject { |method_name| store.respond_to?(method_name) }
144
+ unless missing.empty?
145
+ raise ConfigurationError, "config.task_store is missing methods: #{missing.join(", ")}"
146
+ end
147
+
148
+ store
149
+ end
150
+
57
151
  def validate_agent_name!
58
152
  unless @agent.is_a?(String) && !@agent.strip.empty?
59
153
  raise ConfigurationError, "config.agent must be a non-empty class-name String"
@@ -3,8 +3,9 @@
3
3
  module A2A
4
4
  module Rails
5
5
  class Dispatcher
6
- def initialize(agent:)
6
+ def initialize(agent:, configuration: Configuration.new)
7
7
  @agent = agent
8
+ @configuration = configuration
8
9
  end
9
10
 
10
11
  def validate!
@@ -12,14 +13,41 @@ module A2A
12
13
  self
13
14
  end
14
15
 
15
- def call(message:, context:)
16
+ # Evaluated before creating a Task, so a direct Message leaves no
17
+ # temporary or orphaned Task in the backing store.
18
+ def response_mode(message:)
19
+ validate!
20
+ setting = @agent.response_mode
21
+ mode = setting.respond_to?(:call) ? setting.call(message: message) : setting
22
+ return mode if %i[task message].include?(mode)
23
+
24
+ raise ConfigurationError, "response_mode must resolve to :task or :message"
25
+ end
26
+
27
+ def plan(message:, context:)
16
28
  validate!
17
29
  skill = select_skill(message: message, context: context)
30
+
31
+ ExecutionPlan.new(
32
+ agent_class_name: @agent.name,
33
+ skill_id: skill.id,
34
+ execution_mode: @configuration.resolve_task_execution_mode(agent: @agent, skill: skill)
35
+ )
36
+ end
37
+
38
+ def execute(plan:, message:, context:)
39
+ validate!
40
+ skill = declared_skill(plan.skill_id)
18
41
  handler_context = context.merge(skill_id: skill.id)
19
42
 
20
43
  skill.handler.call(message: message, context: handler_context)
21
44
  end
22
45
 
46
+ def call(message:, context:)
47
+ execution_plan = plan(message: message, context: context)
48
+ execute(plan: execution_plan, message: message, context: context)
49
+ end
50
+
23
51
  private
24
52
 
25
53
  def select_skill(message:, context:)
@@ -33,8 +61,12 @@ module A2A
33
61
  )
34
62
  selected_id = normalize_selected_id(selected)
35
63
 
36
- declared.find { |skill| skill.id == selected_id } ||
37
- raise(UnknownSkillError, "Router selected unknown skill: #{selected.inspect}")
64
+ declared_skill(selected_id, selected)
65
+ end
66
+
67
+ def declared_skill(skill_id, original = skill_id)
68
+ @agent.skills.find { |skill| skill.id == skill_id } ||
69
+ raise(UnknownSkillError, "Router selected unknown skill: #{original.inspect}")
38
70
  end
39
71
 
40
72
  def normalize_selected_id(value)
@@ -38,5 +38,8 @@ module A2A
38
38
 
39
39
  class InvalidTaskQueryError < TaskError; end
40
40
  class InvalidTaskStateError < TaskError; end
41
+ class TaskStoreUnavailableError < TaskError; end
42
+ class TaskStoreCapacityError < TaskError; end
43
+ class TaskStorePayloadLimitError < TaskError; end
41
44
  end
42
45
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ class ExecutionPlan
6
+ attr_reader :agent_class_name, :skill_id, :execution_mode
7
+
8
+ def initialize(agent_class_name:, skill_id:, execution_mode:)
9
+ @agent_class_name = normalize_agent_class_name(agent_class_name)
10
+ @skill_id = normalize_skill_id(skill_id)
11
+ @execution_mode = normalize_execution_mode(execution_mode)
12
+ freeze
13
+ end
14
+
15
+ def async?
16
+ @execution_mode == :async
17
+ end
18
+
19
+ private
20
+
21
+ def normalize_agent_class_name(value)
22
+ return nil if value.nil? || value.to_s.empty?
23
+
24
+ value.to_s.dup.freeze
25
+ end
26
+
27
+ def normalize_skill_id(value)
28
+ value.to_sym
29
+ rescue NoMethodError
30
+ raise ConfigurationError, "ExecutionPlan skill_id must be convertible to a Symbol"
31
+ end
32
+
33
+ def normalize_execution_mode(value)
34
+ return value if Configuration::TASK_EXECUTION_MODES.include?(value)
35
+
36
+ raise ConfigurationError, "ExecutionPlan execution_mode must be :sync or :async"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "uri"
5
+
6
+ module A2A
7
+ module Rails
8
+ # Explicit Handler result for a single A2A v1.0 file Artifact.
9
+ #
10
+ # FileArtifact.bytes(data: File.binread(path), filename: "report.pdf", media_type: "application/pdf")
11
+ # FileArtifact.url(url: "https://files.example.com/report.pdf", filename: "report.pdf", media_type: "application/pdf")
12
+ #
13
+ # URLs are only *described* on the wire. The Gem never downloads them,
14
+ # authorizes recipients, generates signed URLs or hosts files.
15
+ class FileArtifact
16
+ MAX_INLINE_BYTES = 1_048_576
17
+ MAX_URL_BYTES = 2_048
18
+ MAX_FILENAME_BYTES = 255
19
+ MIME_TYPE = /\A[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+\z/
20
+
21
+ def self.bytes(data:, filename:, media_type:)
22
+ unless data.is_a?(String) && data.bytesize <= MAX_INLINE_BYTES
23
+ raise ArtifactMappingError, "inline file data must be a String of at most 1 MiB"
24
+ end
25
+
26
+ new(content_kind: :raw, content: Base64.strict_encode64(data.b),
27
+ filename: filename, media_type: media_type)
28
+ end
29
+
30
+ def self.url(url:, filename:, media_type:)
31
+ unless url.is_a?(String) && !url.empty? && url.bytesize <= MAX_URL_BYTES &&
32
+ !url.match?(/[[:cntrl:]]/)
33
+ raise ArtifactMappingError, "file URL must be an absolute HTTPS URL (at most 2048 bytes)"
34
+ end
35
+
36
+ parsed = URI.parse(url)
37
+ unless parsed.is_a?(URI::HTTPS) && !parsed.host.to_s.empty? &&
38
+ parsed.userinfo.nil? && parsed.fragment.nil?
39
+ raise ArtifactMappingError, "file URL must use HTTPS without credentials or a fragment"
40
+ end
41
+
42
+ new(content_kind: :url, content: url.dup,
43
+ filename: filename, media_type: media_type)
44
+ rescue URI::InvalidURIError
45
+ raise ArtifactMappingError, "invalid file URL"
46
+ end
47
+
48
+ def to_part
49
+ {
50
+ @content_kind => @content,
51
+ filename: @filename,
52
+ media_type: @media_type
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def initialize(content_kind:, content:, filename:, media_type:)
59
+ unless filename.is_a?(String) && !filename.empty? &&
60
+ filename.bytesize <= MAX_FILENAME_BYTES &&
61
+ !filename.include?("/") && !filename.include?("\\") &&
62
+ !filename.match?(/[[:cntrl:]]/) && !%w[. ..].include?(filename)
63
+ raise ArtifactMappingError, "filename must be a safe basename (at most 255 bytes)"
64
+ end
65
+ unless media_type.is_a?(String) && MIME_TYPE.match?(media_type)
66
+ raise ArtifactMappingError, "media_type must be a valid MIME type such as application/pdf"
67
+ end
68
+
69
+ @content_kind = content_kind
70
+ @content = content.freeze
71
+ @filename = filename.dup.freeze
72
+ @media_type = media_type.dup.freeze
73
+ freeze
74
+ end
75
+ end
76
+ end
77
+ end
@@ -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")