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,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
|