a2a-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module Protocol
6
+ class Adapter
7
+ def call(_env)
8
+ raise NotImplementedError, "Protocol adapters must implement #call"
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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