ask-runtime 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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tool_call"
4
+ require_relative "../tool_result"
5
+ require_relative "../execution_context"
6
+
7
+ module Ask
8
+ module Runtime
9
+ module Events
10
+ # Immutable event emitted when a tool call exceeds its execution time
11
+ # limit and is terminated.
12
+ #
13
+ # Carries the terminal ToolCall snapshot (state: :timed_out), the
14
+ # ToolResult with outcome: :timeout, the ExecutionContext, a monotonic
15
+ # timestamp, and the wall-clock duration in seconds.
16
+ #
17
+ # event = ToolTimedOut.new(
18
+ # tool_call: finished_call, tool_result: result,
19
+ # execution_context: ctx, timestamp: Time.now, duration: 30.0
20
+ # )
21
+ # event.timed_out? #=> true
22
+ # event.duration #=> 30.0
23
+ #
24
+ class ToolTimedOut
25
+ attr_reader :tool_call, :tool_result, :execution_context, :timestamp, :duration
26
+
27
+ def initialize(tool_call:, tool_result:, execution_context:, timestamp:, duration:)
28
+ raise ArgumentError, "tool_call required" unless tool_call.is_a?(Ask::Runtime::ToolCall)
29
+ raise ArgumentError, "tool_result required" unless tool_result.is_a?(Ask::Runtime::ToolResult)
30
+ raise ArgumentError, "execution_context required" unless execution_context.is_a?(Ask::Runtime::ExecutionContext)
31
+ raise ArgumentError, "timestamp required" unless timestamp.is_a?(Time)
32
+
33
+ @tool_call = tool_call
34
+ @tool_result = tool_result
35
+ @execution_context = execution_context
36
+ @timestamp = timestamp
37
+ @duration = duration
38
+ freeze
39
+ end
40
+
41
+ def tool_call_id = @tool_call.id
42
+ def tool_name = @tool_call.tool_name
43
+ def timed_out? = @tool_result.timeout?
44
+
45
+ def to_h
46
+ {
47
+ tool_call: @tool_call,
48
+ tool_result: @tool_result,
49
+ execution_context: @execution_context,
50
+ timestamp: @timestamp,
51
+ duration: @duration,
52
+ tool_call_id: tool_call_id,
53
+ tool_name: tool_name
54
+ }
55
+ end
56
+
57
+ def inspect
58
+ "#<ToolTimedOut tool=#{tool_name.inspect} id=#{tool_call_id.inspect} duration=#{@duration}>"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "events/tool_started"
4
+ require_relative "events/tool_completed"
5
+ require_relative "events/tool_failed"
6
+ require_relative "events/tool_cancelled"
7
+ require_relative "events/tool_timed_out"
8
+
9
+ module Ask
10
+ module Runtime
11
+ # Immutable event value objects for tool-call lifecycle notifications.
12
+ #
13
+ # Each event is a frozen +Data.define+ instance carrying a snapshot of
14
+ # the ToolCall, ToolResult (when terminal), ExecutionContext, timestamp,
15
+ # and duration (when terminal).
16
+ #
17
+ # Events are emitted through an +EventSink+ and can be observed by
18
+ # listeners without coupling to the executor implementation.
19
+ #
20
+ # @see EventSink
21
+ # @see ToolCall
22
+ # @see ToolResult
23
+ module Events
24
+ # @!method tool_call
25
+ # @return [ToolCall] snapshot of the tool call at the time of the event
26
+
27
+ # @!method execution_context
28
+ # @return [ExecutionContext] the execution context for this tool call
29
+
30
+ # @!method timestamp
31
+ # @return [Time] wall-clock time when the event was created
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "canceller"
4
+ require_relative "event_sink"
5
+
6
+ module Ask
7
+ module Runtime
8
+ # Immutable context for tool execution.
9
+ #
10
+ # ExecutionContext bundles everything a tool executor needs beyond the
11
+ # ToolCall itself: session/turn correlation, caller identity, workspace,
12
+ # capabilities, cancellation support, event emission, artifact storage,
13
+ # and arbitrary metadata.
14
+ #
15
+ # All attributes are set at construction time and frozen.
16
+ #
17
+ # ctx = Ask::Runtime::ExecutionContext.new(
18
+ # session_id: "s_001",
19
+ # turn: 3,
20
+ # caller_id: "agent_main",
21
+ # workspace: "/tmp/work",
22
+ # capabilities: [:file_read, :file_write]
23
+ # )
24
+ #
25
+ class ExecutionContext
26
+ # @return [String, nil] session identifier
27
+ attr_reader :session_id
28
+
29
+ # @return [Integer, nil] current turn number
30
+ attr_reader :turn
31
+
32
+ # @return [String, nil] caller identifier
33
+ attr_reader :caller_id
34
+
35
+ # @return [String, nil] workspace root directory
36
+ attr_reader :workspace
37
+
38
+ # @return [Array<Symbol>] capability symbols this execution may use
39
+ attr_reader :capabilities
40
+
41
+ # @return [Canceller] cooperative cancellation token
42
+ attr_reader :canceller
43
+
44
+ # @return [EventSink] event emitter for lifecycle notifications
45
+ attr_reader :event_sink
46
+
47
+ # @return [Hash] artifact store (tool_name => artifact data)
48
+ attr_reader :artifact_store
49
+
50
+ # @return [Hash] arbitrary metadata
51
+ attr_reader :metadata
52
+
53
+ # @return [Time] when this context was created
54
+ attr_reader :created_at
55
+
56
+ def initialize(session_id: nil, turn: nil, caller_id: nil,
57
+ workspace: nil, capabilities: [],
58
+ canceller: nil, event_sink: nil,
59
+ artifact_store: nil, metadata: {},
60
+ created_at: Time.now)
61
+ @session_id = session_id
62
+ @turn = turn
63
+ @caller_id = caller_id
64
+ @workspace = workspace
65
+ @capabilities = Array(capabilities).freeze
66
+ @canceller = canceller || Canceller.new
67
+ @event_sink = event_sink || EventSink.new
68
+ @artifact_store = artifact_store ? artifact_store.dup.freeze : {}.freeze
69
+ @metadata = metadata.dup.freeze
70
+ @created_at = created_at
71
+ freeze
72
+ end
73
+
74
+ # @return [Boolean] whether this context is correlated to a session
75
+ def session?
76
+ !@session_id.nil?
77
+ end
78
+
79
+ # @return [Boolean] whether a specific capability is available
80
+ def has_capability?(cap)
81
+ @capabilities.include?(cap)
82
+ end
83
+
84
+ # @return [Boolean] whether the execution has been cancelled
85
+ def cancelled?
86
+ @canceller.cancelled?
87
+ end
88
+
89
+ # Return a new ExecutionContext with the given attributes replaced.
90
+ #
91
+ # @param attrs [Hash] attributes to override
92
+ # @return [ExecutionContext] a new frozen instance
93
+ def with(**attrs)
94
+ self.class.new(**to_h.merge(attrs))
95
+ end
96
+
97
+ # @return [Hash] serializable representation
98
+ def to_h
99
+ {
100
+ session_id: @session_id,
101
+ turn: @turn,
102
+ caller_id: @caller_id,
103
+ workspace: @workspace,
104
+ capabilities: @capabilities,
105
+ canceller: @canceller,
106
+ event_sink: @event_sink,
107
+ artifact_store: @artifact_store,
108
+ metadata: @metadata,
109
+ created_at: @created_at
110
+ }
111
+ end
112
+
113
+ def inspect
114
+ "#<Ask::Runtime::ExecutionContext session=#{@session_id.inspect} turn=#{@turn.inspect} caller=#{@caller_id.inspect}>"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Runtime
5
+ module Testing
6
+ # Minitest assertions for checking a ToolExecutor implementation.
7
+ #
8
+ # Include this module in an adapter test class and call
9
+ # +assert_conforms_to_runtime_contract+ with representative calls:
10
+ #
11
+ # include Ask::Runtime::Testing::ExecutorContract
12
+ #
13
+ # assert_conforms_to_runtime_contract(
14
+ # executor,
15
+ # success_call: success_call,
16
+ # failure_call: failure_call,
17
+ # cancelled_call: cancelled_call,
18
+ # context_factory: ->(event_sink:) { build_context(event_sink:) }
19
+ # )
20
+ #
21
+ # The adapter remains responsible for backend-specific tests; this
22
+ # helper checks the shared result, cancellation, event, and correlation
23
+ # contract that every runtime executor must provide.
24
+ module ExecutorContract
25
+ def assert_conforms_to_runtime_contract(executor, success_call:, failure_call:,
26
+ cancelled_call:, context_factory:)
27
+ assert_respond_to executor, :execute
28
+
29
+ success_sink = Ask::Runtime::EventSink.new
30
+ success_events = []
31
+ observe_events(success_sink, success_events)
32
+ success_context = context_factory.call(event_sink: success_sink)
33
+ success_result = executor.execute(success_call, context: success_context)
34
+
35
+ assert_instance_of Ask::Runtime::ToolResult, success_result
36
+ assert_predicate success_result, :success?
37
+ refute_nil success_result.duration
38
+ assert_operator success_result.duration, :>=, 0
39
+ assert_equal %i[tool_started tool_completed], success_events.map(&:first)
40
+
41
+ terminal_event = success_events.last.last
42
+ assert_equal success_call.id, terminal_event.tool_call_id
43
+ assert_equal success_call.session_id, terminal_event.tool_call.session_id
44
+ assert_equal :completed, terminal_event.tool_call.state
45
+
46
+ failure_sink = Ask::Runtime::EventSink.new
47
+ failure_events = []
48
+ observe_events(failure_sink, failure_events)
49
+ failure_result = executor.execute(
50
+ failure_call, context: context_factory.call(event_sink: failure_sink)
51
+ )
52
+ assert_instance_of Ask::Runtime::ToolResult, failure_result
53
+ assert_predicate failure_result, :failure?
54
+ assert_equal %i[tool_started tool_failed], failure_events.map(&:first)
55
+
56
+ canceller = Ask::Runtime::Canceller.new
57
+ canceller.cancel
58
+ cancellation_sink = Ask::Runtime::EventSink.new
59
+ cancellation_events = []
60
+ observe_events(cancellation_sink, cancellation_events)
61
+ cancellation_context = context_factory.call(event_sink: cancellation_sink, canceller: canceller)
62
+ cancelled_result = executor.execute(
63
+ cancelled_call,
64
+ context: cancellation_context
65
+ )
66
+ assert_instance_of Ask::Runtime::ToolResult, cancelled_result
67
+ assert_predicate cancelled_result, :cancelled?
68
+ assert_equal %i[tool_started tool_cancelled], cancellation_events.map(&:first)
69
+ end
70
+
71
+ private
72
+
73
+ def observe_events(sink, events)
74
+ %i[tool_started tool_completed tool_failed tool_cancelled tool_timed_out].each do |type|
75
+ sink.on(type) { |payload| events << [type, payload[:event]] }
76
+ end
77
+ end
78
+
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "testing/executor_contract"
4
+
5
+ module Ask
6
+ module Runtime
7
+ # Optional helpers for adapter authors writing runtime contract tests.
8
+ module Testing
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "canceller"
5
+ require_relative "event_sink"
6
+
7
+ module Ask
8
+ module Runtime
9
+ # Immutable value object representing a single tool-call request.
10
+ #
11
+ # A ToolCall is created by the LLM (or a caller) to invoke a named tool
12
+ # with structured input. It carries identity, correlation data, and
13
+ # lifecycle state that progresses through:
14
+ #
15
+ # :pending → :running → :completed | :failed | :cancelled | :timed_out
16
+ #
17
+ # ToolCall instances are frozen after initialization.
18
+ #
19
+ # call = Ask::Runtime::ToolCall.new(
20
+ # id: "tc_abc",
21
+ # tool_name: "search",
22
+ # input: { query: "ruby" },
23
+ # session_id: "s_001",
24
+ # turn: 2
25
+ # )
26
+ # call.pending? # => true
27
+ #
28
+ class ToolCall
29
+ STATES = %i[pending running completed failed cancelled timed_out].freeze
30
+
31
+ # @return [String] unique identifier for this tool call
32
+ attr_reader :id
33
+
34
+ # @return [String] name of the tool to execute
35
+ attr_reader :tool_name
36
+
37
+ # @return [Hash] the tool's input parameters
38
+ attr_reader :input
39
+
40
+ # @return [String, nil] session identifier for correlation
41
+ attr_reader :session_id
42
+
43
+ # @return [Integer, nil] turn number within the session
44
+ attr_reader :turn
45
+
46
+ # @return [Symbol] lifecycle state (:pending, :running, :completed,
47
+ # :cancelled, :timed_out)
48
+ attr_reader :state
49
+
50
+ # @return [String, nil] caller identifier (e.g. agent name or user id)
51
+ attr_reader :caller_id
52
+
53
+ # @return [String, nil] error message when state is :failed
54
+ attr_reader :error
55
+
56
+ # @return [ToolResult, nil] the normalized execution result, set on
57
+ # terminal states (:completed, :failed, :cancelled, :timed_out)
58
+ attr_reader :tool_result
59
+
60
+ # @return [Hash] arbitrary metadata
61
+ attr_reader :metadata
62
+
63
+ # @return [Time] when this tool call was created
64
+ attr_reader :created_at
65
+
66
+ # @return [Time, nil] when execution started
67
+ attr_reader :started_at
68
+
69
+ # @return [Time, nil] when execution finished
70
+ attr_reader :finished_at
71
+
72
+ def initialize(id: nil, tool_name:, input: {}, session_id: nil,
73
+ turn: nil, state: :pending, caller_id: nil,
74
+ error: nil, tool_result: nil,
75
+ metadata: {}, created_at: Time.now, started_at: nil,
76
+ finished_at: nil)
77
+ @id = id || "tc_#{SecureRandom.hex(8)}"
78
+ @tool_name = tool_name.to_s
79
+ @input = input.dup.freeze
80
+ @session_id = session_id
81
+ @turn = turn
82
+ @state = validate_state!(state)
83
+ @caller_id = caller_id
84
+ @error = error
85
+ @tool_result = tool_result
86
+ @metadata = metadata.dup.freeze
87
+ @created_at = created_at
88
+ @started_at = started_at
89
+ @finished_at = finished_at
90
+ freeze
91
+ end
92
+
93
+ # @!group State Predicates
94
+
95
+ # @return [Boolean]
96
+ def pending? = @state == :pending
97
+
98
+ # @return [Boolean]
99
+ def running? = @state == :running
100
+
101
+ # @return [Boolean]
102
+ def completed? = @state == :completed
103
+
104
+ # @return [Boolean]
105
+ def failed? = @state == :failed
106
+
107
+ # @return [Boolean]
108
+ def cancelled? = @state == :cancelled
109
+
110
+ # @return [Boolean]
111
+ def timed_out? = @state == :timed_out
112
+
113
+ # @!endgroup
114
+
115
+ # Return a new ToolCall with the given attributes replaced.
116
+ #
117
+ # @param attrs [Hash] attributes to override
118
+ # @return [ToolCall] a new frozen instance
119
+ def with(**attrs)
120
+ self.class.new(**to_h.merge(attrs))
121
+ end
122
+
123
+ # @return [Hash] representation suitable for Ruby serialization.
124
+ # Note: +created_at+, +started_at+, and +finished_at+ are +Time+
125
+ # objects and are NOT directly JSON-serializable. Callers must
126
+ # convert them (e.g. +.iso8601+) before encoding to JSON.
127
+ def to_h
128
+ {
129
+ id: @id,
130
+ tool_name: @tool_name,
131
+ input: @input,
132
+ session_id: @session_id,
133
+ turn: @turn,
134
+ state: @state,
135
+ caller_id: @caller_id,
136
+ error: @error,
137
+ tool_result: @tool_result&.to_h,
138
+ metadata: @metadata,
139
+ created_at: @created_at,
140
+ started_at: @started_at,
141
+ finished_at: @finished_at
142
+ }
143
+ end
144
+
145
+ def inspect
146
+ "#<Ask::Runtime::ToolCall id=#{@id.inspect} tool=#{@tool_name.inspect} state=#{@state.inspect}>"
147
+ end
148
+
149
+ def ==(other)
150
+ other.is_a?(self.class) && @id == other.id
151
+ end
152
+ alias eql? ==
153
+
154
+ def hash
155
+ @id.hash
156
+ end
157
+
158
+ private
159
+
160
+ def validate_state!(state)
161
+ return state if STATES.include?(state)
162
+
163
+ raise ArgumentError, "Invalid state #{state.inspect}. Valid: #{STATES.join(', ')}"
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Runtime
5
+ # Adapter contract for tool execution backends.
6
+ #
7
+ # ToolExecutor is the duck-type interface that concrete adapters must
8
+ # implement. It does not depend on any LLM or provider code — adapters
9
+ # bridge between the runtime kernel and specific tool implementations.
10
+ #
11
+ # A minimal adapter:
12
+ #
13
+ # class MyExecutor
14
+ # include Ask::Runtime::ToolExecutor
15
+ #
16
+ # def execute(tool_call, context: nil)
17
+ # # run the tool, return Ask::Runtime::ToolResult
18
+ # end
19
+ # end
20
+ #
21
+ # Or implement the interface directly without including the module:
22
+ #
23
+ # class MyExecutor
24
+ # def execute(tool_call, context: nil)
25
+ # # ...
26
+ # end
27
+ # end
28
+ #
29
+ module ToolExecutor
30
+ # Execute a tool call within the given context.
31
+ #
32
+ # @param tool_call [ToolCall] the tool-call request to execute
33
+ # @param context [ExecutionContext, nil] optional execution context
34
+ # @return [ToolResult] the normalized execution result
35
+ # @raise [NotImplementedError] if not overridden
36
+ def execute(tool_call, context: nil)
37
+ raise NotImplementedError,
38
+ "#{self.class} must implement #execute(tool_call, context:)"
39
+ end
40
+
41
+ # Check whether this executor can handle a given tool name.
42
+ #
43
+ # @param tool_name [String, Symbol] the tool name to check
44
+ # @return [Boolean] whether this executor handles the tool
45
+ def handles?(tool_name)
46
+ respond_to?(:supported_tools) &&
47
+ supported_tools.include?(tool_name.to_s)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask"
4
+
5
+ module Ask
6
+ module Runtime
7
+ # Normalized result of tool execution.
8
+ #
9
+ # ToolResult wraps +Ask::Result+ and adds lifecycle-specific predicates
10
+ # for cancelled and timed-out states that are not part of the core result
11
+ # vocabulary but are natural outcomes of the runtime execution model.
12
+ #
13
+ # Factory methods produce canonical results:
14
+ #
15
+ # Ask::Runtime::ToolResult.success(data: { "count" => 42 })
16
+ # Ask::Runtime::ToolResult.failure("file not found")
17
+ # Ask::Runtime::ToolResult.cancelled("user aborted")
18
+ # Ask::Runtime::ToolResult.timeout("exceeded 30s limit")
19
+ #
20
+ class ToolResult
21
+ # @return [Ask::Result] the underlying core result
22
+ attr_reader :result
23
+
24
+ # @return [Symbol] normalized outcome (:success, :failure, :cancelled, :timeout)
25
+ attr_reader :outcome
26
+
27
+ # @return [Float, nil] execution duration in seconds
28
+ attr_reader :duration
29
+
30
+ def initialize(result:, outcome:, duration: nil)
31
+ @result = result
32
+ @outcome = outcome
33
+ @duration = duration
34
+ freeze
35
+ end
36
+
37
+ class << self
38
+ # Create a successful tool result.
39
+ #
40
+ # @param data [Object] the tool's output payload
41
+ # @param duration [Float, nil] execution time in seconds
42
+ # @param metadata [Hash] additional metadata
43
+ # @return [ToolResult]
44
+ def success(data: nil, duration: nil, metadata: {})
45
+ result = Ask::Result.ok(data: data, metadata: metadata)
46
+ new(result: result, outcome: :success, duration: duration)
47
+ end
48
+
49
+ # Create a failed tool result.
50
+ #
51
+ # @param message [String] error description
52
+ # @param duration [Float, nil] execution time in seconds
53
+ # @param metadata [Hash] additional metadata
54
+ # @return [ToolResult]
55
+ def failure(message, duration: nil, metadata: {})
56
+ result = Ask::Result.error(message: message, metadata: metadata)
57
+ new(result: result, outcome: :failure, duration: duration)
58
+ end
59
+
60
+ # Create a cancelled tool result (cooperative cancellation).
61
+ #
62
+ # @param reason [String] why execution was cancelled
63
+ # @param duration [Float, nil] execution time before cancellation
64
+ # @return [ToolResult]
65
+ def cancelled(reason = "Cancelled", duration: nil)
66
+ result = Ask::Result.failure(reason)
67
+ new(result: result, outcome: :cancelled, duration: duration)
68
+ end
69
+
70
+ # Create a timed-out tool result.
71
+ #
72
+ # @param message [String] timeout description
73
+ # @param duration [Float, nil] elapsed time before timeout
74
+ # @return [ToolResult]
75
+ def timeout(message = "Execution timed out", duration: nil)
76
+ result = Ask::Result.failure(message)
77
+ new(result: result, outcome: :timeout, duration: duration)
78
+ end
79
+ end
80
+
81
+ # @!group Predicates
82
+
83
+ # @return [Boolean]
84
+ def success? = @outcome == :success
85
+
86
+ # @return [Boolean]
87
+ def failure? = @outcome == :failure
88
+
89
+ # @return [Boolean]
90
+ def cancelled? = @outcome == :cancelled
91
+
92
+ # @return [Boolean]
93
+ def timeout? = @outcome == :timeout
94
+
95
+ # @!endgroup
96
+
97
+ # @return [Object, nil] the output data when successful
98
+ def output = @result.output
99
+
100
+ # @return [Object, nil] the error message or object
101
+ def error_message = @result.error_message
102
+
103
+ # @return [Hash] serializable representation
104
+ def to_h
105
+ {
106
+ outcome: @outcome,
107
+ output: output,
108
+ error: error_message,
109
+ duration: @duration,
110
+ result: @result.to_h
111
+ }
112
+ end
113
+
114
+ def inspect
115
+ if success?
116
+ "#<Ask::Runtime::ToolResult outcome=success output=#{output.inspect}>"
117
+ else
118
+ "#<Ask::Runtime::ToolResult outcome=#{@outcome.inspect} error=#{error_message.inspect}>"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Runtime
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime/version"
4
+ require_relative "runtime/tool_call"
5
+ require_relative "runtime/tool_result"
6
+ require_relative "runtime/execution_context"
7
+ require_relative "runtime/event_sink"
8
+ require_relative "runtime/events"
9
+ require_relative "runtime/tool_executor"
10
+
11
+ module Ask
12
+ # The runtime execution kernel for ask-rb tool calls.
13
+ #
14
+ # Provides value objects (ToolCall, ToolResult, ExecutionContext) and
15
+ # the ToolExecutor adapter contract for pluggable tool execution.
16
+ #
17
+ # This gem does not depend on any LLM provider code. It defines the
18
+ # stable public kernel that higher-level gems build on.
19
+ module Runtime
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/runtime"