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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +499 -0
- data/app/controllers/a2a/rails/agent_cards_controller.rb +12 -0
- data/app/controllers/a2a/rails/application_controller.rb +8 -0
- data/app/controllers/a2a/rails/requests_controller.rb +18 -0
- data/config/routes.rb +6 -0
- data/lib/a2a/rails/agent.rb +106 -0
- data/lib/a2a/rails/agent_card/builder.rb +95 -0
- data/lib/a2a/rails/agent_card/validator.rb +109 -0
- data/lib/a2a/rails/configuration.rb +96 -0
- data/lib/a2a/rails/dispatcher.rb +47 -0
- data/lib/a2a/rails/engine.rb +24 -0
- data/lib/a2a/rails/errors.rb +42 -0
- data/lib/a2a/rails/protocol/adapter.rb +13 -0
- data/lib/a2a/rails/protocol/agent2agent_adapter.rb +125 -0
- data/lib/a2a/rails/protocol/request_handler.rb +172 -0
- data/lib/a2a/rails/protocol/task_mapper.rb +106 -0
- data/lib/a2a/rails/runtime.rb +43 -0
- data/lib/a2a/rails/skill.rb +92 -0
- data/lib/a2a/rails/task/artifact_mapper.rb +38 -0
- data/lib/a2a/rails/task/lifecycle.rb +98 -0
- data/lib/a2a/rails/task/memory_store.rb +161 -0
- data/lib/a2a/rails/task/result_mapper.rb +32 -0
- data/lib/a2a/rails/task/store.rb +29 -0
- data/lib/a2a/rails/task.rb +16 -0
- data/lib/a2a/rails/version.rb +7 -0
- data/lib/a2a-rails.rb +36 -0
- data/lib/generators/a2a/rails/agent_generator.rb +20 -0
- data/lib/generators/a2a/rails/install_generator.rb +16 -0
- data/lib/generators/a2a/rails/templates/agent.rb.tt +7 -0
- data/lib/generators/a2a/rails/templates/initializer.rb.tt +4 -0
- metadata +192 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Rails
|
|
5
|
+
class Agent
|
|
6
|
+
UNSET = Object.new.freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def name(value = UNSET)
|
|
10
|
+
return super() if value.equal?(UNSET)
|
|
11
|
+
|
|
12
|
+
@a2a_name = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def agent_name
|
|
16
|
+
@a2a_name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def description(value = UNSET)
|
|
20
|
+
return @a2a_description if value.equal?(UNSET)
|
|
21
|
+
|
|
22
|
+
@a2a_description = value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def version(value = UNSET)
|
|
26
|
+
return @a2a_version if value.equal?(UNSET)
|
|
27
|
+
|
|
28
|
+
@a2a_version = value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def router(value = UNSET)
|
|
32
|
+
return @a2a_router if value.equal?(UNSET)
|
|
33
|
+
|
|
34
|
+
@a2a_router = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def skill(id, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil)
|
|
38
|
+
definition = Skill.new(
|
|
39
|
+
id: id,
|
|
40
|
+
name: name,
|
|
41
|
+
description: description,
|
|
42
|
+
tags: tags,
|
|
43
|
+
handler: handler,
|
|
44
|
+
examples: examples,
|
|
45
|
+
input_modes: input_modes,
|
|
46
|
+
output_modes: output_modes
|
|
47
|
+
)
|
|
48
|
+
skill_definitions << definition
|
|
49
|
+
definition
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def skills
|
|
53
|
+
skill_definitions.dup.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate!
|
|
57
|
+
validate_metadata!
|
|
58
|
+
validate_skills!
|
|
59
|
+
validate_router!
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def skill_definitions
|
|
66
|
+
@a2a_skills ||= []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_metadata!
|
|
70
|
+
{
|
|
71
|
+
name: @a2a_name,
|
|
72
|
+
description: @a2a_description,
|
|
73
|
+
version: @a2a_version
|
|
74
|
+
}.each do |field, value|
|
|
75
|
+
next unless value.nil? || value.to_s.strip.empty?
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError, "#{self} must define #{field}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_skills!
|
|
82
|
+
if skill_definitions.empty?
|
|
83
|
+
raise ConfigurationError, "#{self} must define at least one skill"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
duplicate = skill_definitions.group_by(&:id).find { |_id, definitions| definitions.length > 1 }
|
|
87
|
+
if duplicate
|
|
88
|
+
raise ConfigurationError, "#{self} defines duplicate skill id: #{duplicate.first.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
skill_definitions.each(&:validate!)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_router!
|
|
95
|
+
if skill_definitions.length > 1 && @a2a_router.nil?
|
|
96
|
+
raise ConfigurationError, "#{self} defines multiple skills but no Router is configured"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return if @a2a_router.nil? || @a2a_router.respond_to?(:call)
|
|
100
|
+
|
|
101
|
+
raise ConfigurationError, "Router #{@a2a_router.inspect} must respond to .call"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module A2A
|
|
6
|
+
module Rails
|
|
7
|
+
module AgentCard
|
|
8
|
+
class Builder
|
|
9
|
+
DEFAULT_INPUT_MODES = ["text/plain"].freeze
|
|
10
|
+
DEFAULT_OUTPUT_MODES = ["text/plain"].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(agent:, public_base_url: nil, request_base_url: nil, validator: Validator.new)
|
|
13
|
+
@agent = agent
|
|
14
|
+
@public_base_url = public_base_url
|
|
15
|
+
@request_base_url = request_base_url
|
|
16
|
+
@validator = validator
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
@agent.validate!
|
|
21
|
+
|
|
22
|
+
card = {
|
|
23
|
+
"name" => @agent.agent_name.to_s,
|
|
24
|
+
"description" => @agent.description.to_s,
|
|
25
|
+
"version" => @agent.version.to_s,
|
|
26
|
+
"supportedInterfaces" => [
|
|
27
|
+
{
|
|
28
|
+
"url" => endpoint_url,
|
|
29
|
+
"protocolBinding" => "JSONRPC",
|
|
30
|
+
"protocolVersion" => "1.0"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"capabilities" => {
|
|
34
|
+
"streaming" => false,
|
|
35
|
+
"pushNotifications" => false,
|
|
36
|
+
"extendedAgentCard" => false
|
|
37
|
+
},
|
|
38
|
+
"defaultInputModes" => DEFAULT_INPUT_MODES.dup,
|
|
39
|
+
"defaultOutputModes" => DEFAULT_OUTPUT_MODES.dup,
|
|
40
|
+
"skills" => @agent.skills.map { |skill| skill_hash(skill) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@validator.validate!(card)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def endpoint_url
|
|
49
|
+
base = @public_base_url
|
|
50
|
+
base = @request_base_url if base.nil? || base.to_s.strip.empty?
|
|
51
|
+
if base.nil? || base.to_s.strip.empty?
|
|
52
|
+
raise ConfigurationError, "Agent Card requires public_base_url or request_base_url"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
normalized = normalize_base_url(base.to_s)
|
|
56
|
+
"#{normalized}/a2a"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_base_url(value)
|
|
60
|
+
uri = URI.parse(value.strip)
|
|
61
|
+
unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
|
|
62
|
+
raise ConfigurationError, "Agent Card base URL must be an absolute HTTP(S) URL"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if uri.userinfo || uri.query || uri.fragment
|
|
66
|
+
raise ConfigurationError, "Agent Card base URL must not contain userinfo, query, or fragment"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
segments = uri.path.to_s.split("/").reject(&:empty?)
|
|
70
|
+
if segments.include?("a2a")
|
|
71
|
+
raise ConfigurationError, "Agent Card base URL must not contain /a2a"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
uri.path = uri.path.to_s.sub(%r{/+\z}, "")
|
|
75
|
+
uri.to_s.sub(%r{/+\z}, "")
|
|
76
|
+
rescue URI::InvalidURIError
|
|
77
|
+
raise ConfigurationError, "Agent Card base URL must be an absolute HTTP(S) URL"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def skill_hash(skill)
|
|
81
|
+
data = {
|
|
82
|
+
"id" => skill.id.to_s,
|
|
83
|
+
"name" => skill.name,
|
|
84
|
+
"description" => skill.description,
|
|
85
|
+
"tags" => skill.tags.dup
|
|
86
|
+
}
|
|
87
|
+
data["examples"] = skill.examples.dup unless skill.examples.nil?
|
|
88
|
+
data["inputModes"] = skill.input_modes.dup unless skill.input_modes.nil?
|
|
89
|
+
data["outputModes"] = skill.output_modes.dup unless skill.output_modes.nil?
|
|
90
|
+
data
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module A2A
|
|
6
|
+
module Rails
|
|
7
|
+
module AgentCard
|
|
8
|
+
class Validator
|
|
9
|
+
def validate!(card)
|
|
10
|
+
unless card.is_a?(Hash)
|
|
11
|
+
raise ConfigurationError, "Agent Card must be a Hash"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
validate_text!(card, "name")
|
|
15
|
+
validate_text!(card, "description")
|
|
16
|
+
validate_text!(card, "version")
|
|
17
|
+
validate_interfaces!(card["supportedInterfaces"])
|
|
18
|
+
validate_capabilities!(card["capabilities"])
|
|
19
|
+
validate_string_array!(card["defaultInputModes"], "defaultInputModes", allow_empty: false)
|
|
20
|
+
validate_string_array!(card["defaultOutputModes"], "defaultOutputModes", allow_empty: false)
|
|
21
|
+
validate_skills!(card["skills"])
|
|
22
|
+
card
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_text!(hash, key)
|
|
28
|
+
value = hash[key]
|
|
29
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
30
|
+
|
|
31
|
+
raise ConfigurationError, "Agent Card #{key} must be a non-empty String"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_interfaces!(interfaces)
|
|
35
|
+
unless interfaces.is_a?(Array) && !interfaces.empty?
|
|
36
|
+
raise ConfigurationError, "Agent Card supportedInterfaces must contain at least one interface"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
interfaces.each do |interface|
|
|
40
|
+
unless interface.is_a?(Hash)
|
|
41
|
+
raise ConfigurationError, "Agent Card interface must be a Hash"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
validate_http_url!(interface["url"])
|
|
45
|
+
unless interface["protocolBinding"] == "JSONRPC" && interface["protocolVersion"] == "1.0"
|
|
46
|
+
raise ConfigurationError, "Agent Card v0.1 interface must use JSONRPC protocol version 1.0"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_http_url!(value)
|
|
52
|
+
uri = URI.parse(value.to_s)
|
|
53
|
+
return if %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty? && uri.query.nil? && uri.fragment.nil?
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "Agent Card interface URL must be an absolute HTTP(S) URL"
|
|
56
|
+
rescue URI::InvalidURIError
|
|
57
|
+
raise ConfigurationError, "Agent Card interface URL must be an absolute HTTP(S) URL"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_capabilities!(capabilities)
|
|
61
|
+
unless capabilities.is_a?(Hash)
|
|
62
|
+
raise ConfigurationError, "Agent Card capabilities must be a Hash"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
%w[streaming pushNotifications extendedAgentCard].each do |key|
|
|
66
|
+
unless capabilities[key] == false
|
|
67
|
+
raise ConfigurationError, "Agent Card v0.1 capability #{key} must be false"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_skills!(skills)
|
|
73
|
+
unless skills.is_a?(Array) && !skills.empty?
|
|
74
|
+
raise ConfigurationError, "Agent Card skills must contain at least one Skill"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
ids = skills.map do |skill|
|
|
78
|
+
unless skill.is_a?(Hash)
|
|
79
|
+
raise ConfigurationError, "Agent Card Skill must be a Hash"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if skill.key?("handler") || skill.key?(:handler)
|
|
83
|
+
raise ConfigurationError, "Agent Card must not expose Handler internals"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
%w[id name description].each { |key| validate_text!(skill, key) }
|
|
87
|
+
validate_string_array!(skill["tags"], "Skill tags", allow_empty: false)
|
|
88
|
+
validate_string_array!(skill["examples"], "Skill examples", allow_empty: true) if skill.key?("examples")
|
|
89
|
+
validate_string_array!(skill["inputModes"], "Skill inputModes", allow_empty: false) if skill.key?("inputModes")
|
|
90
|
+
validate_string_array!(skill["outputModes"], "Skill outputModes", allow_empty: false) if skill.key?("outputModes")
|
|
91
|
+
skill["id"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
duplicate = ids.group_by(&:itself).find { |_id, matches| matches.length > 1 }
|
|
95
|
+
raise ConfigurationError, "Agent Card defines duplicate Skill id: #{duplicate.first}" if duplicate
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_string_array!(value, label, allow_empty:)
|
|
99
|
+
valid = value.is_a?(Array) &&
|
|
100
|
+
(allow_empty || !value.empty?) &&
|
|
101
|
+
value.all? { |entry| entry.is_a?(String) && !entry.strip.empty? }
|
|
102
|
+
return if valid
|
|
103
|
+
|
|
104
|
+
raise ConfigurationError, "Agent Card #{label} must be an Array of non-empty Strings"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module A2A
|
|
6
|
+
module Rails
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :agent, :public_base_url
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@agent = nil
|
|
12
|
+
@public_base_url = nil
|
|
13
|
+
@logger_set = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def logger
|
|
17
|
+
return @logger if @logger_set
|
|
18
|
+
return ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
19
|
+
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def logger=(value)
|
|
24
|
+
@logger_set = true
|
|
25
|
+
@logger = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate!
|
|
29
|
+
validate_agent_name!
|
|
30
|
+
normalized_public_base_url
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_agent
|
|
35
|
+
validate_agent_name!
|
|
36
|
+
|
|
37
|
+
resolved = constantize(@agent.strip)
|
|
38
|
+
unless resolved.is_a?(Class) && resolved <= A2A::Rails::Agent
|
|
39
|
+
raise ConfigurationError, "Configured agent must inherit from A2A::Rails::Agent"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
resolved.validate!
|
|
43
|
+
resolved
|
|
44
|
+
rescue NameError
|
|
45
|
+
raise ConfigurationError, "Configured agent could not be resolved"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalized_public_base_url
|
|
49
|
+
value = @public_base_url
|
|
50
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
51
|
+
|
|
52
|
+
normalize_base_url(value.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate_agent_name!
|
|
58
|
+
unless @agent.is_a?(String) && !@agent.strip.empty?
|
|
59
|
+
raise ConfigurationError, "config.agent must be a non-empty class-name String"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
parts = @agent.strip.sub(/\A::/, "").split("::")
|
|
63
|
+
unless parts.all? { |part| part.match?(/\A[A-Z]\w*\z/) }
|
|
64
|
+
raise ConfigurationError, "config.agent must be a valid class-name String"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def constantize(name)
|
|
69
|
+
name.sub(/\A::/, "").split("::").reduce(Object) do |namespace, constant_name|
|
|
70
|
+
namespace.const_get(constant_name, false)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def normalize_base_url(value)
|
|
75
|
+
uri = URI.parse(value.strip)
|
|
76
|
+
unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
|
|
77
|
+
raise ConfigurationError, "config.public_base_url must be an absolute HTTP(S) URL"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if uri.userinfo || uri.query || uri.fragment
|
|
81
|
+
raise ConfigurationError, "config.public_base_url must not contain userinfo, query, or fragment"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
segments = uri.path.to_s.split("/").reject(&:empty?)
|
|
85
|
+
if segments.include?("a2a")
|
|
86
|
+
raise ConfigurationError, "config.public_base_url must not contain /a2a"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
uri.path = uri.path.to_s.sub(%r{/+\z}, "")
|
|
90
|
+
uri.to_s.sub(%r{/+\z}, "")
|
|
91
|
+
rescue URI::InvalidURIError
|
|
92
|
+
raise ConfigurationError, "config.public_base_url must be an absolute HTTP(S) URL"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Rails
|
|
5
|
+
class Dispatcher
|
|
6
|
+
def initialize(agent:)
|
|
7
|
+
@agent = agent
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def validate!
|
|
11
|
+
@agent.validate!
|
|
12
|
+
self
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(message:, context:)
|
|
16
|
+
validate!
|
|
17
|
+
skill = select_skill(message: message, context: context)
|
|
18
|
+
handler_context = context.merge(skill_id: skill.id)
|
|
19
|
+
|
|
20
|
+
skill.handler.call(message: message, context: handler_context)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def select_skill(message:, context:)
|
|
26
|
+
declared = @agent.skills
|
|
27
|
+
return declared.first if declared.one?
|
|
28
|
+
|
|
29
|
+
selected = @agent.router.call(
|
|
30
|
+
message: message,
|
|
31
|
+
context: context,
|
|
32
|
+
skills: declared.map(&:id).freeze
|
|
33
|
+
)
|
|
34
|
+
selected_id = normalize_selected_id(selected)
|
|
35
|
+
|
|
36
|
+
declared.find { |skill| skill.id == selected_id } ||
|
|
37
|
+
raise(UnknownSkillError, "Router selected unknown skill: #{selected.inspect}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_selected_id(value)
|
|
41
|
+
value.to_sym
|
|
42
|
+
rescue NoMethodError
|
|
43
|
+
raise UnknownSkillError, "Router selected unknown skill: #{value.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/module/delegation"
|
|
5
|
+
require "action_dispatch"
|
|
6
|
+
require "action_controller/api"
|
|
7
|
+
require "rails/engine"
|
|
8
|
+
require_relative "../../../app/controllers/a2a/rails/application_controller"
|
|
9
|
+
require_relative "../../../app/controllers/a2a/rails/agent_cards_controller"
|
|
10
|
+
require_relative "../../../app/controllers/a2a/rails/requests_controller"
|
|
11
|
+
|
|
12
|
+
module A2A
|
|
13
|
+
module Rails
|
|
14
|
+
class Engine < ::Rails::Engine
|
|
15
|
+
isolate_namespace A2A::Rails
|
|
16
|
+
|
|
17
|
+
initializer "a2a-rails.mount_engine" do |app|
|
|
18
|
+
app.routes.append do
|
|
19
|
+
mount A2A::Rails::Engine => "/"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A2A
|
|
4
|
+
module Rails
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
class InvalidHandlerError < ConfigurationError; end
|
|
9
|
+
class UnknownSkillError < ConfigurationError; end
|
|
10
|
+
|
|
11
|
+
class RejectedTask < Error; end
|
|
12
|
+
class ArtifactMappingError < Error; end
|
|
13
|
+
|
|
14
|
+
class ProtocolError < Error; end
|
|
15
|
+
class InvalidRequestError < ProtocolError; end
|
|
16
|
+
class ContentTypeNotSupportedError < ProtocolError; end
|
|
17
|
+
class TaskContinuationNotSupportedError < ProtocolError; end
|
|
18
|
+
|
|
19
|
+
class TaskError < Error; end
|
|
20
|
+
class TaskNotFoundError < TaskError
|
|
21
|
+
attr_reader :task_id
|
|
22
|
+
|
|
23
|
+
def initialize(task_id)
|
|
24
|
+
@task_id = task_id
|
|
25
|
+
super("Task not found: #{task_id}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class TaskNotCancelableError < TaskError
|
|
30
|
+
attr_reader :task_id, :state
|
|
31
|
+
|
|
32
|
+
def initialize(task_id, state:)
|
|
33
|
+
@task_id = task_id
|
|
34
|
+
@state = state
|
|
35
|
+
super("Task #{task_id} is not cancelable from #{state}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class InvalidTaskQueryError < TaskError; end
|
|
40
|
+
class InvalidTaskStateError < TaskError; end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "a2a"
|
|
4
|
+
require "rack"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require_relative "adapter"
|
|
7
|
+
require_relative "task_mapper"
|
|
8
|
+
|
|
9
|
+
module A2A
|
|
10
|
+
module Rails
|
|
11
|
+
module Protocol
|
|
12
|
+
class Agent2AgentAdapter < Adapter
|
|
13
|
+
PROTOCOL_VERSION = "1.0"
|
|
14
|
+
SUPPORTED_OPERATIONS = %w[SendMessage GetTask ListTasks CancelTask].freeze
|
|
15
|
+
RESPONSE_SCHEMAS = {
|
|
16
|
+
"SendMessage" => "Send Message Response",
|
|
17
|
+
"GetTask" => "Task",
|
|
18
|
+
"ListTasks" => "List Tasks Response",
|
|
19
|
+
"CancelTask" => "Task"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(agent_card:, request_handler:, sdk_factory: nil)
|
|
23
|
+
unless request_handler.respond_to?(:call)
|
|
24
|
+
raise ArgumentError, "request_handler must respond to #call"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@request_handler = request_handler
|
|
28
|
+
factory = sdk_factory || A2A.method(:agent)
|
|
29
|
+
@sdk = factory.call(agent_card: agent_card) { |env| dispatch(env) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(env)
|
|
33
|
+
status, headers, body = with_sensitive_sdk_logging_suppressed do
|
|
34
|
+
@sdk.call(normalize_env(env))
|
|
35
|
+
end
|
|
36
|
+
[status, headers.merge("a2a-version" => PROTOCOL_VERSION), body]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def with_sensitive_sdk_logging_suppressed
|
|
42
|
+
return yield unless defined?(::Console) && ::Console.respond_to?(:logger)
|
|
43
|
+
return yield unless defined?(::Console::Logger::WARN) && defined?(::A2A::Server::Triage)
|
|
44
|
+
|
|
45
|
+
logger = ::Console.logger
|
|
46
|
+
return yield unless logger.respond_to?(:subjects)
|
|
47
|
+
|
|
48
|
+
subjects = logger.subjects
|
|
49
|
+
triage = ::A2A::Server::Triage
|
|
50
|
+
had_override = subjects.key?(triage)
|
|
51
|
+
previous_level = subjects[triage]
|
|
52
|
+
subjects[triage] = ::Console::Logger::WARN
|
|
53
|
+
|
|
54
|
+
yield
|
|
55
|
+
ensure
|
|
56
|
+
if defined?(subjects) && subjects
|
|
57
|
+
if had_override
|
|
58
|
+
subjects[triage] = previous_level
|
|
59
|
+
else
|
|
60
|
+
subjects.delete(triage)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_env(env)
|
|
66
|
+
input = env.fetch("rack.input")
|
|
67
|
+
env.merge(
|
|
68
|
+
"PATH_INFO" => "/",
|
|
69
|
+
"rack.input" => StringIO.new(input.read.to_s)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dispatch(env)
|
|
74
|
+
validate_version!(env)
|
|
75
|
+
|
|
76
|
+
operation = env.fetch("a2a.operation")
|
|
77
|
+
raise A2A::UnsupportedOperationError.new unless SUPPORTED_OPERATIONS.include?(operation)
|
|
78
|
+
|
|
79
|
+
request = env.fetch("a2a.request")
|
|
80
|
+
validate_request!(request)
|
|
81
|
+
|
|
82
|
+
result = @request_handler.call(operation: operation, params: request.to_h)
|
|
83
|
+
schema(RESPONSE_SCHEMAS.fetch(operation), result)
|
|
84
|
+
rescue A2A::Rails::TaskNotFoundError => error
|
|
85
|
+
raise A2A::TaskNotFoundError.new(error.task_id)
|
|
86
|
+
rescue A2A::Rails::TaskNotCancelableError => error
|
|
87
|
+
raise A2A::TaskNotCancelableError.new(
|
|
88
|
+
error.task_id,
|
|
89
|
+
state: TaskMapper.wire_state(error.state)
|
|
90
|
+
)
|
|
91
|
+
rescue A2A::Rails::InvalidRequestError,
|
|
92
|
+
A2A::Rails::InvalidTaskQueryError,
|
|
93
|
+
A2A::Rails::InvalidTaskStateError => error
|
|
94
|
+
raise A2A::InvalidParamsError.new(error.message)
|
|
95
|
+
rescue A2A::Rails::ContentTypeNotSupportedError
|
|
96
|
+
raise A2A::ContentTypeNotSupportedError.new
|
|
97
|
+
rescue A2A::Rails::TaskContinuationNotSupportedError => error
|
|
98
|
+
raise A2A::UnsupportedOperationError.new(message: error.message)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_version!(env)
|
|
102
|
+
version = env["HTTP_A2A_VERSION"]
|
|
103
|
+
if version.nil? || version.empty?
|
|
104
|
+
version = Rack::Utils.parse_query(env["QUERY_STRING"].to_s)["A2A-Version"]
|
|
105
|
+
end
|
|
106
|
+
version = "0.3" if version.nil? || version.empty?
|
|
107
|
+
|
|
108
|
+
raise A2A::VersionNotSupportedError.new(version) unless version == PROTOCOL_VERSION
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_request!(request)
|
|
112
|
+
request.valid!
|
|
113
|
+
rescue A2A::Protocol::JsonSchema::ValidationError
|
|
114
|
+
raise A2A::InvalidParamsError.new("Invalid request parameters")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def schema(name, value)
|
|
118
|
+
object = A2A::Protocol::JsonSchema[name].new(value)
|
|
119
|
+
object.valid!
|
|
120
|
+
object
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|