a2a-rails 0.1.0 → 0.2.0.rc2
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 +56 -0
- data/README.md +261 -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/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
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module A2A
|
|
4
6
|
module Rails
|
|
5
7
|
module Protocol
|
|
6
8
|
class RequestHandler
|
|
7
|
-
def initialize(dispatcher:, lifecycle:, task_mapper: TaskMapper.new)
|
|
9
|
+
def initialize(dispatcher:, lifecycle:, task_mapper: TaskMapper.new, task_job: TaskExecutionJob)
|
|
8
10
|
@dispatcher = dispatcher
|
|
9
11
|
@lifecycle = lifecycle
|
|
10
12
|
@task_mapper = task_mapper
|
|
13
|
+
@task_job = task_job
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def call(operation:, params:)
|
|
@@ -34,22 +37,96 @@ module A2A
|
|
|
34
37
|
history_length = history_length(params)
|
|
35
38
|
message, context_id = normalize_message(params)
|
|
36
39
|
|
|
40
|
+
# Direct Message is an explicit host-side choice, never inferred
|
|
41
|
+
# from an untrusted client flag or forced by the protocol adapter.
|
|
42
|
+
if safe_response_mode(message) == :message
|
|
43
|
+
return send_direct_message(message, context_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
37
46
|
task = @lifecycle.create(message: message, context_id: context_id)
|
|
38
|
-
|
|
39
|
-
context = { task_id: task.fetch(:id), context_id: task.fetch(:context_id) }
|
|
47
|
+
context = execution_context(task)
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@lifecycle.complete(task.fetch(:id), result)
|
|
49
|
+
plan = begin
|
|
50
|
+
@dispatcher.plan(message: message, context: context)
|
|
44
51
|
rescue RejectedTask => error
|
|
45
|
-
@lifecycle.reject(task.fetch(:id), error)
|
|
52
|
+
task = @lifecycle.reject(task.fetch(:id), error)
|
|
53
|
+
nil
|
|
46
54
|
rescue StandardError => error
|
|
47
|
-
@lifecycle.fail(task.fetch(:id), error)
|
|
55
|
+
task = @lifecycle.fail(task.fetch(:id), error)
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if plan&.async?
|
|
60
|
+
task = enqueue_async_task(task, plan)
|
|
61
|
+
elsif plan
|
|
62
|
+
@lifecycle.start(task.fetch(:id))
|
|
63
|
+
task = begin
|
|
64
|
+
result = @dispatcher.execute(plan: plan, message: message, context: context)
|
|
65
|
+
@lifecycle.complete(task.fetch(:id), result)
|
|
66
|
+
rescue RejectedTask => error
|
|
67
|
+
@lifecycle.reject(task.fetch(:id), error)
|
|
68
|
+
rescue StandardError => error
|
|
69
|
+
@lifecycle.fail(task.fetch(:id), error)
|
|
70
|
+
end
|
|
48
71
|
end
|
|
49
72
|
|
|
50
73
|
{ "task" => @task_mapper.dump(task, history_length: history_length, include_artifacts: true) }
|
|
51
74
|
end
|
|
52
75
|
|
|
76
|
+
def enqueue_async_task(task, plan)
|
|
77
|
+
arguments = TaskExecutionArguments.new(
|
|
78
|
+
task_id: task.fetch(:id),
|
|
79
|
+
principal_id: @lifecycle.principal_id,
|
|
80
|
+
agent_class_name: plan.agent_class_name,
|
|
81
|
+
skill_id: plan.skill_id
|
|
82
|
+
)
|
|
83
|
+
job = @task_job.perform_later(**arguments.to_h)
|
|
84
|
+
enqueue_error = job.respond_to?(:enqueue_error) ? job.enqueue_error : nil
|
|
85
|
+
successfully_enqueued = if job && job.respond_to?(:successfully_enqueued?)
|
|
86
|
+
job.successfully_enqueued?
|
|
87
|
+
else
|
|
88
|
+
!!job
|
|
89
|
+
end
|
|
90
|
+
return task if successfully_enqueued && enqueue_error.nil?
|
|
91
|
+
|
|
92
|
+
@lifecycle.fail(
|
|
93
|
+
task.fetch(:id),
|
|
94
|
+
enqueue_error || ConfigurationError.new("Task execution job was not enqueued")
|
|
95
|
+
)
|
|
96
|
+
rescue StandardError => error
|
|
97
|
+
@lifecycle.fail(task.fetch(:id), error)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def execution_context(task)
|
|
101
|
+
{
|
|
102
|
+
task_id: task.fetch(:id),
|
|
103
|
+
context_id: task.fetch(:context_id),
|
|
104
|
+
principal_id: @lifecycle.principal_id,
|
|
105
|
+
idempotency_key: task.fetch(:id)
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def safe_response_mode(message)
|
|
110
|
+
@dispatcher.response_mode(message: message)
|
|
111
|
+
rescue StandardError
|
|
112
|
+
# The selector is host application code and may raise with secrets.
|
|
113
|
+
# No Task exists yet to record the failure as a Task status.
|
|
114
|
+
raise A2A::InvalidAgentResponseError.new
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def send_direct_message(message, context_id)
|
|
118
|
+
context_id = SecureRandom.uuid if context_id.nil? || context_id.empty?
|
|
119
|
+
result = @dispatcher.call(
|
|
120
|
+
message: message,
|
|
121
|
+
context: { task_id: nil, context_id: context_id }
|
|
122
|
+
)
|
|
123
|
+
{ "message" => @task_mapper.dump_direct_message(result, context_id: context_id) }
|
|
124
|
+
rescue StandardError
|
|
125
|
+
# No Task exists to record a FAILED status. Never send application
|
|
126
|
+
# exception details or unsupported result values to A2A callers.
|
|
127
|
+
raise A2A::InvalidAgentResponseError.new
|
|
128
|
+
end
|
|
129
|
+
|
|
53
130
|
def get_task(params)
|
|
54
131
|
history_length = history_length(params)
|
|
55
132
|
task = @lifecycle.find(required_id(params))
|
|
@@ -58,6 +135,7 @@ module A2A
|
|
|
58
135
|
|
|
59
136
|
def list_tasks(params)
|
|
60
137
|
history_length = history_length(params)
|
|
138
|
+
validate_list_filters!(params)
|
|
61
139
|
filters = {}
|
|
62
140
|
filters[:context_id] = params["contextId"] if params.key?("contextId")
|
|
63
141
|
filters[:status] = @task_mapper.internal_state(params["status"]) if params.key?("status")
|
|
@@ -103,8 +181,21 @@ module A2A
|
|
|
103
181
|
raise InvalidRequestError, "nonempty parts are required"
|
|
104
182
|
end
|
|
105
183
|
|
|
106
|
-
|
|
107
|
-
|
|
184
|
+
task_id = raw["taskId"]
|
|
185
|
+
if !task_id.nil? && !task_id.is_a?(String)
|
|
186
|
+
raise InvalidRequestError, "taskId must be a String"
|
|
187
|
+
end
|
|
188
|
+
context_id = raw["contextId"]
|
|
189
|
+
if !context_id.nil? && !context_id.is_a?(String)
|
|
190
|
+
raise InvalidRequestError, "contextId must be a String"
|
|
191
|
+
end
|
|
192
|
+
metadata = raw["metadata"]
|
|
193
|
+
unless metadata.nil? || metadata.is_a?(Hash)
|
|
194
|
+
raise InvalidRequestError, "message metadata must be an object"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if task_id.is_a?(String) && !task_id.empty?
|
|
198
|
+
@lifecycle.find(task_id)
|
|
108
199
|
raise TaskContinuationNotSupportedError, "Task continuation is not supported in v0.1"
|
|
109
200
|
end
|
|
110
201
|
|
|
@@ -113,25 +204,45 @@ module A2A
|
|
|
113
204
|
message_id: message_id,
|
|
114
205
|
role: :user,
|
|
115
206
|
parts: normalized_parts,
|
|
116
|
-
metadata: copy(
|
|
207
|
+
metadata: copy(metadata || {})
|
|
117
208
|
}
|
|
118
209
|
|
|
119
|
-
[message,
|
|
210
|
+
[message, context_id]
|
|
120
211
|
end
|
|
121
212
|
|
|
122
213
|
def normalize_text_part(part)
|
|
123
214
|
unless part.is_a?(Hash) && part["text"].is_a?(String) && (part.keys & %w[data raw url]).empty?
|
|
124
215
|
raise ContentTypeNotSupportedError, "Only text Parts are supported in v0.1"
|
|
125
216
|
end
|
|
217
|
+
media_type = part["mediaType"]
|
|
218
|
+
unless media_type.nil? || media_type.is_a?(String)
|
|
219
|
+
raise InvalidRequestError, "mediaType must be a String"
|
|
220
|
+
end
|
|
221
|
+
metadata = part["metadata"]
|
|
222
|
+
unless metadata.nil? || metadata.is_a?(Hash)
|
|
223
|
+
raise InvalidRequestError, "part metadata must be an object"
|
|
224
|
+
end
|
|
126
225
|
|
|
127
226
|
normalized = {
|
|
128
227
|
text: part["text"],
|
|
129
228
|
media_type: part["mediaType"].to_s.empty? ? "text/plain" : part["mediaType"]
|
|
130
229
|
}
|
|
131
|
-
normalized[:metadata] = copy(
|
|
230
|
+
normalized[:metadata] = copy(metadata) if metadata
|
|
132
231
|
normalized
|
|
133
232
|
end
|
|
134
233
|
|
|
234
|
+
def validate_list_filters!(params)
|
|
235
|
+
if params.key?("contextId") && !params["contextId"].nil? && !params["contextId"].is_a?(String)
|
|
236
|
+
raise InvalidRequestError, "contextId must be a String"
|
|
237
|
+
end
|
|
238
|
+
if params.key?("pageToken") && !params["pageToken"].is_a?(String)
|
|
239
|
+
raise InvalidRequestError, "pageToken must be a String"
|
|
240
|
+
end
|
|
241
|
+
if params.key?("includeArtifacts") && ![true, false].include?(params["includeArtifacts"])
|
|
242
|
+
raise InvalidRequestError, "includeArtifacts must be boolean"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
135
246
|
def required_id(params)
|
|
136
247
|
id = params["id"]
|
|
137
248
|
unless id.is_a?(String) && !id.empty?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module A2A
|
|
4
6
|
module Rails
|
|
5
7
|
module Protocol
|
|
@@ -33,6 +35,19 @@ module A2A
|
|
|
33
35
|
result
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
def dump_direct_message(value, context_id:)
|
|
39
|
+
artifact = Task::ArtifactMapper.new.call(value)
|
|
40
|
+
unless artifact
|
|
41
|
+
raise ArtifactMappingError, "Direct Message requires a non-nil Handler result"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
dump_message(
|
|
45
|
+
message_id: SecureRandom.uuid,
|
|
46
|
+
role: :agent,
|
|
47
|
+
parts: artifact.fetch(:parts)
|
|
48
|
+
).merge("contextId" => context_id)
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
def internal_state(value)
|
|
37
52
|
return nil if value.nil?
|
|
38
53
|
|
|
@@ -86,6 +101,12 @@ module A2A
|
|
|
86
101
|
result = {}
|
|
87
102
|
result["text"] = part[:text] if part.key?(:text)
|
|
88
103
|
result["data"] = copy(part[:data]) if part.key?(:data)
|
|
104
|
+
result["raw"] = part[:raw] if part.key?(:raw)
|
|
105
|
+
result["url"] = part[:url] if part.key?(:url)
|
|
106
|
+
result["filename"] = part[:filename] if part.key?(:filename)
|
|
107
|
+
if part.key?(:raw) || part.key?(:url)
|
|
108
|
+
result["mediaType"] = part[:media_type] if part.key?(:media_type)
|
|
109
|
+
end
|
|
89
110
|
result["metadata"] = copy(part[:metadata]) if part.key?(:metadata)
|
|
90
111
|
result
|
|
91
112
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module A2A
|
|
7
|
+
module Rails
|
|
8
|
+
# Enforces a bounded JSON-RPC request body before application callbacks
|
|
9
|
+
# or the upstream SDK can read/parse arbitrary request data.
|
|
10
|
+
module RequestGuard
|
|
11
|
+
DEFAULT_MAX_BYTES = 1_048_576
|
|
12
|
+
MAX_CONFIGURABLE_BYTES = 16 * 1_048_576
|
|
13
|
+
|
|
14
|
+
class PayloadTooLarge < StandardError; end
|
|
15
|
+
class UnsupportedMediaType < StandardError; end
|
|
16
|
+
class InvalidBody < StandardError; end
|
|
17
|
+
class InvalidConfiguration < StandardError; end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def enforce!(env:, max_bytes:)
|
|
22
|
+
unless max_bytes.is_a?(Integer) && max_bytes.between?(1, MAX_CONFIGURABLE_BYTES)
|
|
23
|
+
raise InvalidConfiguration, "max_request_bytes must be between 1 and #{MAX_CONFIGURABLE_BYTES}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
media_type = env["CONTENT_TYPE"].to_s.split(";", 2).first.to_s.strip.downcase
|
|
27
|
+
raise UnsupportedMediaType unless media_type == "application/json"
|
|
28
|
+
|
|
29
|
+
encoding = env["HTTP_CONTENT_ENCODING"].to_s.strip.downcase
|
|
30
|
+
raise UnsupportedMediaType unless encoding.empty? || encoding == "identity"
|
|
31
|
+
|
|
32
|
+
length = env["CONTENT_LENGTH"]
|
|
33
|
+
if length && (!length.is_a?(String) || !length.match?(/\A[0-9]+\z/))
|
|
34
|
+
raise InvalidBody
|
|
35
|
+
end
|
|
36
|
+
raise PayloadTooLarge if length && length.to_i > max_bytes
|
|
37
|
+
|
|
38
|
+
input = env["rack.input"]
|
|
39
|
+
raise InvalidBody unless input.respond_to?(:read)
|
|
40
|
+
|
|
41
|
+
# Read one byte beyond the permitted maximum to catch requests without
|
|
42
|
+
# Content-Length, chunked bodies, and falsely understated lengths.
|
|
43
|
+
body = input.read(max_bytes + 1)
|
|
44
|
+
raise InvalidBody unless body.is_a?(String)
|
|
45
|
+
raise PayloadTooLarge if body.bytesize > max_bytes
|
|
46
|
+
raise InvalidBody if length && length.to_i != body.bytesize
|
|
47
|
+
|
|
48
|
+
validate_sdk_sensitive_shapes!(body)
|
|
49
|
+
|
|
50
|
+
# The SDK receives the *bounded* copy, never the original input stream.
|
|
51
|
+
env["rack.input"] = StringIO.new(body)
|
|
52
|
+
env["CONTENT_LENGTH"] = body.bytesize.to_s
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The upstream agent2agent 2.0.0 ExtractMessage middleware may iterate
|
|
57
|
+
# SendMessage Parts before our RequestHandler validates them, and can
|
|
58
|
+
# raise NoMethodError on a null/non-object Part. Reject those shapes at
|
|
59
|
+
# the bounded HTTP boundary to ensure malformed requests fail safely.
|
|
60
|
+
def validate_sdk_sensitive_shapes!(body)
|
|
61
|
+
payload = JSON.parse(body)
|
|
62
|
+
return unless payload.is_a?(Hash) && payload["method"] == "SendMessage"
|
|
63
|
+
|
|
64
|
+
params = payload["params"]
|
|
65
|
+
message = params["message"] if params.is_a?(Hash)
|
|
66
|
+
parts = message["parts"] if message.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(Hash) }
|
|
69
|
+
raise InvalidBody
|
|
70
|
+
end
|
|
71
|
+
rescue JSON::ParserError, JSON::NestingError
|
|
72
|
+
raise InvalidBody
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/a2a/rails/runtime.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module A2A
|
|
4
4
|
module Rails
|
|
5
5
|
class Runtime
|
|
6
|
-
def initialize(store:
|
|
6
|
+
def initialize(store: nil)
|
|
7
7
|
@store = store
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -14,21 +14,26 @@ module A2A
|
|
|
14
14
|
AgentCard::Builder.new(
|
|
15
15
|
agent: agent,
|
|
16
16
|
public_base_url: configuration.normalized_public_base_url,
|
|
17
|
-
request_base_url: request_base_url
|
|
17
|
+
request_base_url: request_base_url,
|
|
18
|
+
security: AgentCard::Security.new(configuration: configuration).fields
|
|
18
19
|
).call
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def call(env:, request_base_url:)
|
|
22
|
+
def call(env:, request_base_url:, principal_id: nil)
|
|
22
23
|
configuration = A2A::Rails.configuration
|
|
23
24
|
agent = configuration.resolve_agent
|
|
24
25
|
card = AgentCard::Builder.new(
|
|
25
26
|
agent: agent,
|
|
26
27
|
public_base_url: configuration.normalized_public_base_url,
|
|
27
|
-
request_base_url: request_base_url
|
|
28
|
+
request_base_url: request_base_url,
|
|
29
|
+
security: AgentCard::Security.new(configuration: configuration).fields
|
|
28
30
|
).call
|
|
29
|
-
|
|
31
|
+
# The controller has already verified this Rack-scoped principal.
|
|
32
|
+
# Never read identity from A2A JSON-RPC parameters or metadata.
|
|
33
|
+
principal_id ||= env[Authentication::PRINCIPAL_ENV_KEY]
|
|
34
|
+
lifecycle = Task::Lifecycle.new(store: task_store, logger: configuration.logger, principal_id: principal_id)
|
|
30
35
|
request_handler = Protocol::RequestHandler.new(
|
|
31
|
-
dispatcher: Dispatcher.new(agent: agent),
|
|
36
|
+
dispatcher: Dispatcher.new(agent: agent, configuration: configuration),
|
|
32
37
|
lifecycle: lifecycle
|
|
33
38
|
)
|
|
34
39
|
adapter = Protocol::Agent2AgentAdapter.new(
|
|
@@ -38,6 +43,10 @@ module A2A
|
|
|
38
43
|
|
|
39
44
|
adapter.call(env)
|
|
40
45
|
end
|
|
46
|
+
|
|
47
|
+
def task_store
|
|
48
|
+
@store ||= A2A::Rails.configuration.resolve_task_store
|
|
49
|
+
end
|
|
41
50
|
end
|
|
42
51
|
end
|
|
43
52
|
end
|
data/lib/a2a/rails/skill.rb
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module A2A
|
|
4
4
|
module Rails
|
|
5
5
|
class Skill
|
|
6
|
-
attr_reader :id, :name, :description, :tags, :handler, :examples, :input_modes, :output_modes
|
|
6
|
+
attr_reader :id, :name, :description, :tags, :handler, :examples, :input_modes, :output_modes, :execution_mode
|
|
7
7
|
|
|
8
|
-
def initialize(id:, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil)
|
|
8
|
+
def initialize(id:, description:, tags:, handler:, name: nil, examples: nil, input_modes: nil, output_modes: nil, execution_mode: nil)
|
|
9
9
|
@id = normalize_id(id)
|
|
10
10
|
@name = normalize_text(name || humanize_id(@id))
|
|
11
11
|
@description = normalize_optional_text(description)
|
|
@@ -14,6 +14,7 @@ module A2A
|
|
|
14
14
|
@examples = normalize_optional_collection(examples)
|
|
15
15
|
@input_modes = normalize_optional_collection(input_modes)
|
|
16
16
|
@output_modes = normalize_optional_collection(output_modes)
|
|
17
|
+
@execution_mode = execution_mode
|
|
17
18
|
freeze
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -34,6 +35,10 @@ module A2A
|
|
|
34
35
|
validate_optional_strings!(:input_modes, @input_modes, allow_empty: false)
|
|
35
36
|
validate_optional_strings!(:output_modes, @output_modes, allow_empty: false)
|
|
36
37
|
|
|
38
|
+
unless @execution_mode.nil? || Configuration::TASK_EXECUTION_MODES.include?(@execution_mode)
|
|
39
|
+
raise ConfigurationError, "Skill #{@id.inspect} execution_mode must be :sync or :async"
|
|
40
|
+
end
|
|
41
|
+
|
|
37
42
|
unless @handler.respond_to?(:call)
|
|
38
43
|
raise InvalidHandlerError, "Handler #{handler_label} must respond to .call"
|
|
39
44
|
end
|