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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module Protocol
6
+ class RequestHandler
7
+ def initialize(dispatcher:, lifecycle:, task_mapper: TaskMapper.new)
8
+ @dispatcher = dispatcher
9
+ @lifecycle = lifecycle
10
+ @task_mapper = task_mapper
11
+ end
12
+
13
+ def call(operation:, params:)
14
+ validate_common!(params)
15
+
16
+ case operation
17
+ when "SendMessage"
18
+ send_message(params)
19
+ when "GetTask"
20
+ get_task(params)
21
+ when "ListTasks"
22
+ list_tasks(params)
23
+ when "CancelTask"
24
+ cancel_task(params)
25
+ else
26
+ raise InvalidRequestError, "Unsupported operation: #{operation}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def send_message(params)
33
+ @dispatcher.validate!
34
+ history_length = history_length(params)
35
+ message, context_id = normalize_message(params)
36
+
37
+ task = @lifecycle.create(message: message, context_id: context_id)
38
+ @lifecycle.start(task.fetch(:id))
39
+ context = { task_id: task.fetch(:id), context_id: task.fetch(:context_id) }
40
+
41
+ task = begin
42
+ result = @dispatcher.call(message: message, context: context)
43
+ @lifecycle.complete(task.fetch(:id), result)
44
+ rescue RejectedTask => error
45
+ @lifecycle.reject(task.fetch(:id), error)
46
+ rescue StandardError => error
47
+ @lifecycle.fail(task.fetch(:id), error)
48
+ end
49
+
50
+ { "task" => @task_mapper.dump(task, history_length: history_length, include_artifacts: true) }
51
+ end
52
+
53
+ def get_task(params)
54
+ history_length = history_length(params)
55
+ task = @lifecycle.find(required_id(params))
56
+ @task_mapper.dump(task, history_length: history_length, include_artifacts: true)
57
+ end
58
+
59
+ def list_tasks(params)
60
+ history_length = history_length(params)
61
+ filters = {}
62
+ filters[:context_id] = params["contextId"] if params.key?("contextId")
63
+ filters[:status] = @task_mapper.internal_state(params["status"]) if params.key?("status")
64
+ filters[:status_timestamp_after] = params["statusTimestampAfter"] if params.key?("statusTimestampAfter")
65
+ filters[:page_size] = params["pageSize"] if params.key?("pageSize")
66
+ filters[:page_token] = params["pageToken"] if params.key?("pageToken")
67
+
68
+ result = @lifecycle.list(**filters)
69
+ include_artifacts = params["includeArtifacts"] == true
70
+
71
+ {
72
+ "tasks" => result.fetch(:tasks).map do |task|
73
+ @task_mapper.dump(task, history_length: history_length, include_artifacts: include_artifacts)
74
+ end,
75
+ "totalSize" => result.fetch(:total_size),
76
+ "pageSize" => result.fetch(:page_size),
77
+ "nextPageToken" => result.fetch(:next_page_token)
78
+ }
79
+ end
80
+
81
+ def cancel_task(params)
82
+ task = @lifecycle.cancel(required_id(params))
83
+ @task_mapper.dump(task, include_artifacts: true)
84
+ end
85
+
86
+ def normalize_message(params)
87
+ raw = params["message"]
88
+ unless raw.is_a?(Hash)
89
+ raise InvalidRequestError, "message is required"
90
+ end
91
+
92
+ message_id = raw["messageId"]
93
+ unless message_id.is_a?(String) && !message_id.empty?
94
+ raise InvalidRequestError, "messageId is required"
95
+ end
96
+
97
+ unless raw["role"] == "ROLE_USER"
98
+ raise InvalidRequestError, "ROLE_USER is required"
99
+ end
100
+
101
+ parts = raw["parts"]
102
+ unless parts.is_a?(Array) && !parts.empty?
103
+ raise InvalidRequestError, "nonempty parts are required"
104
+ end
105
+
106
+ if raw["taskId"].is_a?(String) && !raw["taskId"].empty?
107
+ @lifecycle.find(raw["taskId"])
108
+ raise TaskContinuationNotSupportedError, "Task continuation is not supported in v0.1"
109
+ end
110
+
111
+ normalized_parts = parts.map { |part| normalize_text_part(part) }
112
+ message = {
113
+ message_id: message_id,
114
+ role: :user,
115
+ parts: normalized_parts,
116
+ metadata: copy(raw["metadata"] || {})
117
+ }
118
+
119
+ [message, raw["contextId"]]
120
+ end
121
+
122
+ def normalize_text_part(part)
123
+ unless part.is_a?(Hash) && part["text"].is_a?(String) && (part.keys & %w[data raw url]).empty?
124
+ raise ContentTypeNotSupportedError, "Only text Parts are supported in v0.1"
125
+ end
126
+
127
+ normalized = {
128
+ text: part["text"],
129
+ media_type: part["mediaType"].to_s.empty? ? "text/plain" : part["mediaType"]
130
+ }
131
+ normalized[:metadata] = copy(part["metadata"]) if part.key?("metadata")
132
+ normalized
133
+ end
134
+
135
+ def required_id(params)
136
+ id = params["id"]
137
+ unless id.is_a?(String) && !id.empty?
138
+ raise InvalidRequestError, "id is required"
139
+ end
140
+
141
+ id
142
+ end
143
+
144
+ def history_length(params)
145
+ return nil unless params.key?("historyLength")
146
+
147
+ value = params["historyLength"]
148
+ unless value.is_a?(Integer) && value >= 0
149
+ raise InvalidRequestError, "historyLength must be a nonnegative integer"
150
+ end
151
+
152
+ value
153
+ end
154
+
155
+ def validate_common!(params)
156
+ unless params.is_a?(Hash)
157
+ raise InvalidRequestError, "params must be an object"
158
+ end
159
+
160
+ tenant = params["tenant"]
161
+ if tenant && !tenant.to_s.empty?
162
+ raise InvalidRequestError, "tenant is not supported in v0.1"
163
+ end
164
+ end
165
+
166
+ def copy(value)
167
+ Marshal.load(Marshal.dump(value))
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ module Protocol
6
+ class TaskMapper
7
+ STATE_TO_WIRE = {
8
+ submitted: "TASK_STATE_SUBMITTED",
9
+ working: "TASK_STATE_WORKING",
10
+ completed: "TASK_STATE_COMPLETED",
11
+ failed: "TASK_STATE_FAILED",
12
+ rejected: "TASK_STATE_REJECTED",
13
+ canceled: "TASK_STATE_CANCELED"
14
+ }.freeze
15
+ WIRE_TO_STATE = STATE_TO_WIRE.invert.freeze
16
+
17
+ def dump(task, history_length: nil, include_artifacts: true)
18
+ result = {
19
+ "id" => task.fetch(:id),
20
+ "contextId" => task.fetch(:context_id),
21
+ "status" => dump_status(task)
22
+ }
23
+
24
+ if task.key?(:history)
25
+ history = project_history(task[:history], history_length)
26
+ result["history"] = history.map { |message| dump_message(message) }
27
+ end
28
+
29
+ if include_artifacts && task.key?(:artifacts)
30
+ result["artifacts"] = task[:artifacts].map { |artifact| dump_artifact(artifact) }
31
+ end
32
+
33
+ result
34
+ end
35
+
36
+ def internal_state(value)
37
+ return nil if value.nil?
38
+
39
+ WIRE_TO_STATE.fetch(value) do
40
+ raise InvalidTaskStateError, "Unknown task state: #{value.inspect}"
41
+ end
42
+ end
43
+
44
+ def self.wire_state(value)
45
+ STATE_TO_WIRE.fetch(value) { value.to_s }
46
+ end
47
+
48
+ private
49
+
50
+ def dump_status(task)
51
+ status = task.fetch(:status)
52
+ result = {
53
+ "state" => self.class.wire_state(status.fetch(:state)),
54
+ "timestamp" => status[:timestamp]
55
+ }
56
+
57
+ if status[:message]
58
+ result["message"] = {
59
+ "messageId" => "status-#{task.fetch(:id)}-#{status.fetch(:state)}",
60
+ "role" => "ROLE_AGENT",
61
+ "parts" => [{ "text" => status[:message].to_s }]
62
+ }
63
+ end
64
+
65
+ result.compact
66
+ end
67
+
68
+ def dump_message(message)
69
+ result = {
70
+ "messageId" => message.fetch(:message_id),
71
+ "role" => message.fetch(:role) == :agent ? "ROLE_AGENT" : "ROLE_USER",
72
+ "parts" => message.fetch(:parts).map { |part| dump_part(part) }
73
+ }
74
+ result["metadata"] = copy(message[:metadata]) if message.key?(:metadata)
75
+ result
76
+ end
77
+
78
+ def dump_artifact(artifact)
79
+ {
80
+ "artifactId" => artifact.fetch(:artifact_id),
81
+ "parts" => artifact.fetch(:parts).map { |part| dump_part(part) }
82
+ }
83
+ end
84
+
85
+ def dump_part(part)
86
+ result = {}
87
+ result["text"] = part[:text] if part.key?(:text)
88
+ result["data"] = copy(part[:data]) if part.key?(:data)
89
+ result["metadata"] = copy(part[:metadata]) if part.key?(:metadata)
90
+ result
91
+ end
92
+
93
+ def project_history(history, history_length)
94
+ return history if history_length.nil?
95
+ return [] if history_length.zero?
96
+
97
+ history.last(history_length)
98
+ end
99
+
100
+ def copy(value)
101
+ Marshal.load(Marshal.dump(value))
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ class Runtime
6
+ def initialize(store: Task::MemoryStore.new)
7
+ @store = store
8
+ end
9
+
10
+ def agent_card(request_base_url:)
11
+ configuration = A2A::Rails.configuration
12
+ agent = configuration.resolve_agent
13
+
14
+ AgentCard::Builder.new(
15
+ agent: agent,
16
+ public_base_url: configuration.normalized_public_base_url,
17
+ request_base_url: request_base_url
18
+ ).call
19
+ end
20
+
21
+ def call(env:, request_base_url:)
22
+ configuration = A2A::Rails.configuration
23
+ agent = configuration.resolve_agent
24
+ card = AgentCard::Builder.new(
25
+ agent: agent,
26
+ public_base_url: configuration.normalized_public_base_url,
27
+ request_base_url: request_base_url
28
+ ).call
29
+ lifecycle = Task::Lifecycle.new(store: @store, logger: configuration.logger)
30
+ request_handler = Protocol::RequestHandler.new(
31
+ dispatcher: Dispatcher.new(agent: agent),
32
+ lifecycle: lifecycle
33
+ )
34
+ adapter = Protocol::Agent2AgentAdapter.new(
35
+ agent_card: card,
36
+ request_handler: request_handler
37
+ )
38
+
39
+ adapter.call(env)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ class Skill
6
+ attr_reader :id, :name, :description, :tags, :handler, :examples, :input_modes, :output_modes
7
+
8
+ def initialize(id:, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil)
9
+ @id = normalize_id(id)
10
+ @name = normalize_text(name || humanize_id(@id))
11
+ @description = normalize_optional_text(description)
12
+ @tags = Array(tags).map { |tag| normalize_text(tag) }.freeze
13
+ @handler = handler
14
+ @examples = normalize_optional_collection(examples)
15
+ @input_modes = normalize_optional_collection(input_modes)
16
+ @output_modes = normalize_optional_collection(output_modes)
17
+ freeze
18
+ end
19
+
20
+ def validate!
21
+ if @id.to_s.empty?
22
+ raise ConfigurationError, "Skill id must not be empty"
23
+ end
24
+
25
+ if @description.nil? || @description.strip.empty?
26
+ raise ConfigurationError, "Skill #{@id.inspect} must define a description"
27
+ end
28
+
29
+ if @tags.empty? || @tags.any? { |tag| tag.strip.empty? }
30
+ raise ConfigurationError, "Skill #{@id.inspect} must define at least one non-empty tag"
31
+ end
32
+
33
+ validate_optional_strings!(:examples, @examples, allow_empty: true)
34
+ validate_optional_strings!(:input_modes, @input_modes, allow_empty: false)
35
+ validate_optional_strings!(:output_modes, @output_modes, allow_empty: false)
36
+
37
+ unless @handler.respond_to?(:call)
38
+ raise InvalidHandlerError, "Handler #{handler_label} must respond to .call"
39
+ end
40
+
41
+ self
42
+ end
43
+
44
+ private
45
+
46
+ def normalize_id(value)
47
+ value.to_sym
48
+ rescue NoMethodError
49
+ raise ConfigurationError, "Skill id must be convertible to a Symbol"
50
+ end
51
+
52
+ def normalize_text(value)
53
+ value.to_s.dup.freeze
54
+ end
55
+
56
+ def normalize_optional_text(value)
57
+ return if value.nil?
58
+
59
+ normalize_text(value)
60
+ end
61
+
62
+ def normalize_optional_collection(value)
63
+ return if value.nil?
64
+ return value unless value.is_a?(Array)
65
+
66
+ value.map { |entry| entry.is_a?(String) ? entry.dup.freeze : entry }.freeze
67
+ end
68
+
69
+ def validate_optional_strings!(field, value, allow_empty:)
70
+ return if value.nil?
71
+
72
+ valid = value.is_a?(Array) &&
73
+ (allow_empty || !value.empty?) &&
74
+ value.all? { |entry| entry.is_a?(String) && !entry.strip.empty? }
75
+ return if valid
76
+
77
+ requirement = allow_empty ? "an Array of non-empty Strings" : "a non-empty Array of non-empty Strings"
78
+ raise ConfigurationError, "Skill #{@id.inspect} #{field} must be #{requirement}"
79
+ end
80
+
81
+ def humanize_id(value)
82
+ value.to_s.split("_").map!(&:capitalize).join(" ")
83
+ end
84
+
85
+ def handler_label
86
+ return @handler.name if @handler.respond_to?(:name) && @handler.name
87
+
88
+ @handler.inspect
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module A2A
6
+ module Rails
7
+ module Task
8
+ class ArtifactMapper
9
+ def initialize(id_generator: -> { SecureRandom.uuid })
10
+ @id_generator = id_generator
11
+ end
12
+
13
+ def call(value)
14
+ case value
15
+ when nil
16
+ nil
17
+ when String
18
+ artifact(parts: [{ text: value }])
19
+ when Hash, Array
20
+ artifact(parts: [{ data: copy(value) }])
21
+ else
22
+ raise ArtifactMappingError, "Unsupported Handler result type: #{value.class}"
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def artifact(parts:)
29
+ { artifact_id: @id_generator.call.to_s, parts: parts }
30
+ end
31
+
32
+ def copy(value)
33
+ Marshal.load(Marshal.dump(value))
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module A2A
6
+ module Rails
7
+ module Task
8
+ class Lifecycle
9
+ def initialize(store:, result_mapper: ResultMapper.new, logger: nil,
10
+ clock: -> { Time.now.utc }, id_generator: -> { SecureRandom.uuid })
11
+ @store = store
12
+ @result_mapper = result_mapper
13
+ @logger = logger
14
+ @clock = clock
15
+ @id_generator = id_generator
16
+ end
17
+
18
+ def create(message:, context_id: nil)
19
+ task = {
20
+ id: next_id,
21
+ context_id: present_context_id(context_id),
22
+ status: status(:submitted),
23
+ history: [copy(message)]
24
+ }
25
+
26
+ @store.save(task)
27
+ end
28
+
29
+ def start(task_id)
30
+ @store.transition(task_id, state: :working, timestamp: now)
31
+ end
32
+
33
+ def complete(task_id, result)
34
+ transition_from_outcome(task_id, @result_mapper.completed(result))
35
+ end
36
+
37
+ def reject(task_id, error)
38
+ transition_from_outcome(task_id, @result_mapper.rejected(error))
39
+ end
40
+
41
+ def fail(task_id, error)
42
+ log_failure(task_id, error)
43
+ transition_from_outcome(task_id, @result_mapper.failed(error))
44
+ end
45
+
46
+ def cancel(task_id)
47
+ @store.cancel(task_id, timestamp: now)
48
+ end
49
+
50
+ def find(task_id)
51
+ @store.find(task_id)
52
+ end
53
+
54
+ def list(**filters)
55
+ @store.list(**filters)
56
+ end
57
+
58
+ private
59
+
60
+ def transition_from_outcome(task_id, outcome)
61
+ attributes = { state: outcome.fetch(:state), timestamp: now }
62
+ attributes[:artifacts] = outcome[:artifacts] if outcome.key?(:artifacts)
63
+ attributes[:message] = outcome[:message] if outcome.key?(:message)
64
+ @store.transition(task_id, **attributes)
65
+ end
66
+
67
+ def status(state)
68
+ { state: state, timestamp: now.iso8601(6) }
69
+ end
70
+
71
+ def present_context_id(context_id)
72
+ return context_id unless context_id.nil? || context_id.to_s.empty?
73
+
74
+ next_id
75
+ end
76
+
77
+ def next_id
78
+ @id_generator.call.to_s
79
+ end
80
+
81
+ def now
82
+ @clock.call.utc
83
+ end
84
+
85
+ def log_failure(task_id, error)
86
+ return unless @logger
87
+
88
+ @logger.error("[a2a-rails] task_id=#{task_id} execution failed: #{error.class}: #{error.message}")
89
+ @logger.error(error.backtrace.join("\n")) if error.backtrace && !error.backtrace.empty?
90
+ end
91
+
92
+ def copy(value)
93
+ Marshal.load(Marshal.dump(value))
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end