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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/lib/ask/runtime/canceller.rb +75 -0
- data/lib/ask/runtime/event_sink.rb +94 -0
- data/lib/ask/runtime/events/tool_cancelled.rb +65 -0
- data/lib/ask/runtime/events/tool_completed.rb +62 -0
- data/lib/ask/runtime/events/tool_failed.rb +64 -0
- data/lib/ask/runtime/events/tool_started.rb +54 -0
- data/lib/ask/runtime/events/tool_timed_out.rb +63 -0
- data/lib/ask/runtime/events.rb +34 -0
- data/lib/ask/runtime/execution_context.rb +118 -0
- data/lib/ask/runtime/testing/executor_contract.rb +82 -0
- data/lib/ask/runtime/testing.rb +11 -0
- data/lib/ask/runtime/tool_call.rb +167 -0
- data/lib/ask/runtime/tool_executor.rb +51 -0
- data/lib/ask/runtime/tool_result.rb +123 -0
- data/lib/ask/runtime/version.rb +7 -0
- data/lib/ask/runtime.rb +21 -0
- data/lib/ask-runtime.rb +3 -0
- metadata +121 -0
|
@@ -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,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
|
data/lib/ask/runtime.rb
ADDED
|
@@ -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
|
data/lib/ask-runtime.rb
ADDED