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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +262 -205
- data/app/controllers/a2a/rails/agent_cards_controller.rb +3 -0
- data/app/controllers/a2a/rails/requests_controller.rb +53 -0
- data/lib/a2a/rails/agent.rb +33 -2
- data/lib/a2a/rails/agent_card/builder.rb +3 -1
- data/lib/a2a/rails/agent_card/security.rb +115 -0
- data/lib/a2a/rails/authentication.rb +71 -0
- data/lib/a2a/rails/configuration.rb +95 -1
- data/lib/a2a/rails/dispatcher.rb +36 -4
- data/lib/a2a/rails/engine.rb +9 -0
- data/lib/a2a/rails/errors.rb +3 -0
- data/lib/a2a/rails/execution_plan.rb +40 -0
- data/lib/a2a/rails/file_artifact.rb +77 -0
- data/lib/a2a/rails/protocol/agent2agent_adapter.rb +11 -0
- data/lib/a2a/rails/protocol/request_handler.rb +124 -13
- data/lib/a2a/rails/protocol/task_mapper.rb +21 -0
- data/lib/a2a/rails/request_guard.rb +76 -0
- data/lib/a2a/rails/runtime.rb +15 -6
- data/lib/a2a/rails/skill.rb +7 -2
- data/lib/a2a/rails/task/active_record_store.rb +451 -0
- data/lib/a2a/rails/task/artifact_mapper.rb +2 -0
- data/lib/a2a/rails/task/lifecycle.rb +18 -8
- data/lib/a2a/rails/task/memory_store.rb +39 -15
- data/lib/a2a/rails/task/store.rb +16 -4
- data/lib/a2a/rails/task_execution_arguments.rb +78 -0
- data/lib/a2a/rails/task_execution_job.rb +79 -0
- data/lib/a2a/rails/version.rb +1 -1
- data/lib/a2a-rails.rb +7 -0
- data/lib/generators/a2a/rails/task_store_generator.rb +46 -0
- data/lib/generators/a2a/rails/templates/create_a2a_rails_tasks.rb.tt +31 -0
- data/lib/generators/a2a/rails/templates/initializer.rb.tt +4 -0
- data/lib/tasks/a2a_rails_tasks.rake +54 -0
- 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
|
data/lib/a2a/rails/agent.rb
CHANGED
|
@@ -34,7 +34,21 @@ module A2A
|
|
|
34
34
|
@a2a_router = value
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def
|
|
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
|
-
|
|
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"
|
data/lib/a2a/rails/dispatcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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)
|
data/lib/a2a/rails/engine.rb
CHANGED
|
@@ -14,6 +14,15 @@ module A2A
|
|
|
14
14
|
class Engine < ::Rails::Engine
|
|
15
15
|
isolate_namespace A2A::Rails
|
|
16
16
|
|
|
17
|
+
# The Gem's app/controllers/a2a/... files define A2A, not A2a.
|
|
18
|
+
# Override only the exact Zeitwerk basename, rather than mutating the
|
|
19
|
+
# host's global ActiveSupport inflections (used by migrations).
|
|
20
|
+
initializer "a2a-rails.zeitwerk_inflection" do
|
|
21
|
+
::Rails.autoloaders.each do |loader|
|
|
22
|
+
loader.inflector.inflect("a2a" => "A2A")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
17
26
|
initializer "a2a-rails.mount_engine" do |app|
|
|
18
27
|
app.routes.append do
|
|
19
28
|
mount A2A::Rails::Engine => "/"
|
data/lib/a2a/rails/errors.rb
CHANGED
|
@@ -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
|