ask-agent 0.40.19 → 0.40.20
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/lib/ask/agent/chat.rb +0 -1
- data/lib/ask/agent/loop.rb +1 -1
- data/lib/ask/agent/session.rb +4 -4
- data/lib/ask/agent/tool_executor.rb +509 -60
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +15 -0
- data/lib/ask-agent.rb +1 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ad845b7f0d67966819af4e0f588f47ff44793e874b56c08dd13208dc3334e5d
|
|
4
|
+
data.tar.gz: 52c5ef1af2a97cb7cb41a7247c19a614e75ca5138f7192ad71ecde7ffdc3eede
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '078e71e0cf4fd80c5d08badd68377eb4b73cf0923f46dc6f065bab94d3106323892bb307acf717ea3640907939e5706645b42986c5d1fc003544801e2fece9e0'
|
|
7
|
+
data.tar.gz: '068595e1f29705bcda12462c342b49a4a5a2d055c5f5f75c5c24edbe02bd8475a8803e6f7ecebb0fddd782dac84e168db8f877d6fe6e2eda566d2bb000e15f26'
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -64,7 +64,6 @@ module Ask
|
|
|
64
64
|
@messages << Ask::Message.new(role: :user, content: merge_attachments(message, attachments)) if has_message || attachments
|
|
65
65
|
|
|
66
66
|
stream = block_given?
|
|
67
|
-
tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
|
|
68
67
|
|
|
69
68
|
calls_acc = {}
|
|
70
69
|
response_msg = chat_with_retry(stream, calls_acc, &block)
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -103,7 +103,7 @@ module Ask
|
|
|
103
103
|
# when the background work completes.
|
|
104
104
|
user_results = tool_executor.execute(
|
|
105
105
|
user_tool_calls, tools, hooks: hooks, event_emitter: event_emitter,
|
|
106
|
-
session_id: session_id,
|
|
106
|
+
session_id: session_id, turn: @turn_count,
|
|
107
107
|
result_callback: lambda do |tool_call_id, result|
|
|
108
108
|
tc = user_tool_calls[tool_call_id]
|
|
109
109
|
next unless tc
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -878,10 +878,10 @@ end
|
|
|
878
878
|
private
|
|
879
879
|
|
|
880
880
|
def build_audit_log(config)
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
881
|
+
config ||= Ask::Agent.configuration.audit_log
|
|
882
|
+
return nil unless config
|
|
883
|
+
Ask::Agent::Policies::AuditLog.new(self, adapter: config)
|
|
884
|
+
end
|
|
885
885
|
|
|
886
886
|
# Build the approval queue + policy when approval is enabled.
|
|
887
887
|
#
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Agent
|
|
5
5
|
class ToolExecutor
|
|
6
|
+
include Ask::Runtime::ToolExecutor
|
|
7
|
+
|
|
6
8
|
CRITICAL_ERROR_CLASSES = %w[
|
|
7
9
|
Ask::Unauthorized
|
|
8
10
|
Ask::Forbidden
|
|
@@ -22,23 +24,41 @@ module Ask
|
|
|
22
24
|
|
|
23
25
|
attr_writer :telemetry
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
# Public entry point — dispatches between two contracts:
|
|
28
|
+
#
|
|
29
|
+
# Batch API: execute(tool_calls, tools, hooks:, event_emitter:, ...)
|
|
30
|
+
# Runtime API: execute(tool_call, context:)
|
|
31
|
+
#
|
|
32
|
+
# The discriminator is the first argument: a Hash triggers the batch path,
|
|
33
|
+
# anything else (typically an Ask::Runtime::ToolCall) triggers the runtime path.
|
|
34
|
+
def execute(tool_calls_or_call, tools_or_context = nil, **rest)
|
|
35
|
+
if tool_calls_or_call.is_a?(Hash)
|
|
36
|
+
execute_batch(tool_calls_or_call, tools_or_context, **rest)
|
|
37
|
+
else
|
|
38
|
+
ctx = rest.key?(:context) ? rest[:context] : tools_or_context
|
|
39
|
+
_runtime_execute(tool_calls_or_call, context: ctx)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute_batch(tool_calls, tools, hooks:, event_emitter:, session_id: nil, turn: nil, result_callback: nil, runtime_event_sink: nil)
|
|
26
44
|
return [] if tool_calls.empty?
|
|
27
45
|
|
|
28
46
|
@total_executions = 0
|
|
29
47
|
@session_id = session_id
|
|
30
|
-
|
|
48
|
+
@turn = turn
|
|
49
|
+
@last_tools = tools
|
|
50
|
+
sibling_abort = CallbackAbortController.new
|
|
31
51
|
|
|
32
52
|
if @parallel
|
|
33
|
-
execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
|
|
53
|
+
execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink, &result_callback)
|
|
34
54
|
else
|
|
35
|
-
execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort) do |id, result|
|
|
55
|
+
execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink) do |id, result|
|
|
36
56
|
result_callback&.call(id, result)
|
|
37
57
|
end
|
|
38
58
|
end
|
|
39
59
|
end
|
|
40
60
|
|
|
41
|
-
def execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
|
|
61
|
+
def execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink, &result_callback)
|
|
42
62
|
threads = []
|
|
43
63
|
mutex = Mutex.new
|
|
44
64
|
results = {}
|
|
@@ -58,7 +78,7 @@ module Ask
|
|
|
58
78
|
next
|
|
59
79
|
end
|
|
60
80
|
|
|
61
|
-
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
|
|
81
|
+
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort, runtime_event_sink)
|
|
62
82
|
mutex.synchronize { results[id] = result }
|
|
63
83
|
|
|
64
84
|
# Stream result back as it completes
|
|
@@ -87,12 +107,12 @@ module Ask
|
|
|
87
107
|
tool_calls.keys.filter_map { |id| results[id]&.merge(tool_call_id: id) }
|
|
88
108
|
end
|
|
89
109
|
|
|
90
|
-
def execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
|
|
110
|
+
def execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink, &result_callback)
|
|
91
111
|
results = []
|
|
92
112
|
tool_calls.each do |id, tool_call|
|
|
93
113
|
break if sibling_abort.aborted?
|
|
94
114
|
|
|
95
|
-
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
|
|
115
|
+
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort, runtime_event_sink)
|
|
96
116
|
results << result.merge(tool_call_id: id)
|
|
97
117
|
result_callback&.call(id, result)
|
|
98
118
|
break if result[:critical_failure]
|
|
@@ -103,7 +123,300 @@ module Ask
|
|
|
103
123
|
|
|
104
124
|
private
|
|
105
125
|
|
|
106
|
-
|
|
126
|
+
class CallbackAbortController < ToolAbortController
|
|
127
|
+
def initialize
|
|
128
|
+
super
|
|
129
|
+
@abort_callbacks = []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def abort!
|
|
133
|
+
callbacks_to_run = @mutex.synchronize do
|
|
134
|
+
was_aborted = @aborted
|
|
135
|
+
@aborted = true
|
|
136
|
+
was_aborted ? [] : @abort_callbacks.dup
|
|
137
|
+
end
|
|
138
|
+
callbacks_to_run.each(&:call)
|
|
139
|
+
self
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def on_abort(&block)
|
|
143
|
+
raise ArgumentError, "block required" unless block
|
|
144
|
+
|
|
145
|
+
already = @mutex.synchronize do
|
|
146
|
+
if @aborted
|
|
147
|
+
true
|
|
148
|
+
else
|
|
149
|
+
@abort_callbacks << block
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
block.call if already
|
|
154
|
+
self
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_runtime_tool_call(tool_call, effective_args: nil)
|
|
159
|
+
input = begin
|
|
160
|
+
raw = effective_args || tool_call.arguments
|
|
161
|
+
parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
162
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
163
|
+
rescue JSON::ParserError
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Ask::Runtime::ToolCall.new(
|
|
168
|
+
id: tool_call.id,
|
|
169
|
+
tool_name: tool_call.name,
|
|
170
|
+
input: input,
|
|
171
|
+
session_id: @session_id,
|
|
172
|
+
turn: @turn
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_runtime_context(abort_controller)
|
|
177
|
+
canceller = AbortControllerCancellerAdapter.new(abort_controller) if abort_controller
|
|
178
|
+
|
|
179
|
+
Ask::Runtime::ExecutionContext.new(
|
|
180
|
+
session_id: @session_id,
|
|
181
|
+
turn: @turn,
|
|
182
|
+
canceller: canceller
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class AbortControllerCancellerAdapter
|
|
187
|
+
def initialize(abort_controller)
|
|
188
|
+
@abort_controller = abort_controller
|
|
189
|
+
@callbacks = []
|
|
190
|
+
@mutex = Mutex.new
|
|
191
|
+
|
|
192
|
+
if abort_controller.respond_to?(:on_abort)
|
|
193
|
+
abort_controller.on_abort { fire_callbacks }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def cancelled?
|
|
198
|
+
@abort_controller.aborted?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def cancel
|
|
202
|
+
@abort_controller.abort!
|
|
203
|
+
fire_callbacks
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def on_cancel(&block)
|
|
208
|
+
raise ArgumentError, "block required" unless block
|
|
209
|
+
|
|
210
|
+
already = @mutex.synchronize do
|
|
211
|
+
if @abort_controller.aborted?
|
|
212
|
+
true
|
|
213
|
+
else
|
|
214
|
+
@callbacks << block
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
block.call if already
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def inspect
|
|
223
|
+
"#<Ask::Agent::ToolExecutor::AbortControllerCancellerAdapter cancelled=#{cancelled?}>"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def fire_callbacks
|
|
229
|
+
callbacks_to_run = @mutex.synchronize do
|
|
230
|
+
snapshot = @callbacks.dup
|
|
231
|
+
@callbacks.clear
|
|
232
|
+
snapshot
|
|
233
|
+
end
|
|
234
|
+
callbacks_to_run.each(&:call)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ----------------------------------------------------------------
|
|
239
|
+
# Runtime contract: execute a single tool call and return ToolResult
|
|
240
|
+
# ----------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
# Implements Ask::Runtime::ToolExecutor#execute.
|
|
243
|
+
#
|
|
244
|
+
# This is the single-call entry point for the runtime contract.
|
|
245
|
+
# It does NOT run hooks, emit agent events, or manage batches — it is
|
|
246
|
+
# the pure runtime execution path that the batch API delegates to
|
|
247
|
+
# internally (via execute_single_tool).
|
|
248
|
+
#
|
|
249
|
+
# @param tool_call [Ask::Runtime::ToolCall] the tool-call request
|
|
250
|
+
# @param context [Ask::Runtime::ExecutionContext, nil] execution context
|
|
251
|
+
# @return [Ask::Runtime::ToolResult] the normalized result
|
|
252
|
+
def _runtime_execute(tool_call, context: nil)
|
|
253
|
+
context ||= Ask::Runtime::ExecutionContext.new
|
|
254
|
+
started_at = Time.now
|
|
255
|
+
started_call = tool_call.with(state: :running, started_at: started_at)
|
|
256
|
+
emit_runtime_event(context.event_sink, :tool_started,
|
|
257
|
+
Ask::Runtime::Events::ToolStarted.new(
|
|
258
|
+
tool_call: started_call, execution_context: context, timestamp: started_at
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
return runtime_cancel(started_call, context, started_at, "Cancelled before execution") if context.cancelled?
|
|
262
|
+
|
|
263
|
+
tool = find_tool_for_runtime(tool_call.tool_name)
|
|
264
|
+
|
|
265
|
+
result = if tool
|
|
266
|
+
invoke_tool_with_retry(tool, tool_call.id, tool_call.input, context.canceller)
|
|
267
|
+
else
|
|
268
|
+
Ask::Runtime::ToolResult.failure("Tool not found: #{tool_call.tool_name}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return runtime_cancel(started_call, context, started_at, "Cancelled during execution") if context.cancelled?
|
|
272
|
+
|
|
273
|
+
runtime_finish(started_call, context, result, started_at)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def runtime_finish(started_call, context, result, started_at)
|
|
277
|
+
finished_at = Time.now
|
|
278
|
+
duration = finished_at - started_at
|
|
279
|
+
result = Ask::Runtime::ToolResult.new(
|
|
280
|
+
result: result.result, outcome: result.outcome, duration: duration
|
|
281
|
+
)
|
|
282
|
+
state = if result.timeout?
|
|
283
|
+
:timed_out
|
|
284
|
+
elsif result.failure?
|
|
285
|
+
:failed
|
|
286
|
+
else
|
|
287
|
+
:completed
|
|
288
|
+
end
|
|
289
|
+
finished_call = started_call.with(
|
|
290
|
+
state: state,
|
|
291
|
+
tool_result: result,
|
|
292
|
+
error: result.error_message,
|
|
293
|
+
finished_at: finished_at
|
|
294
|
+
)
|
|
295
|
+
emit_runtime_terminal_event(
|
|
296
|
+
context.event_sink, state,
|
|
297
|
+
tool_call: finished_call, tool_result: result,
|
|
298
|
+
execution_context: context, timestamp: finished_at, duration: duration
|
|
299
|
+
)
|
|
300
|
+
result
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def runtime_cancel(started_call, context, started_at, reason)
|
|
304
|
+
finished_at = Time.now
|
|
305
|
+
duration = finished_at - started_at
|
|
306
|
+
result = Ask::Runtime::ToolResult.new(
|
|
307
|
+
result: Ask::Result.failure(reason), outcome: :cancelled, duration: duration
|
|
308
|
+
)
|
|
309
|
+
finished_call = started_call.with(
|
|
310
|
+
state: :cancelled,
|
|
311
|
+
tool_result: result,
|
|
312
|
+
error: result.error_message,
|
|
313
|
+
finished_at: finished_at
|
|
314
|
+
)
|
|
315
|
+
emit_runtime_terminal_event(
|
|
316
|
+
context.event_sink, :cancelled,
|
|
317
|
+
tool_call: finished_call, tool_result: result,
|
|
318
|
+
execution_context: context, timestamp: finished_at, duration: duration
|
|
319
|
+
)
|
|
320
|
+
result
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Find a tool by name for the runtime contract path.
|
|
324
|
+
# Uses the last-known tools list from execute_batch, or nil.
|
|
325
|
+
def find_tool_for_runtime(tool_name)
|
|
326
|
+
@last_tools&.find { |t| t.name == tool_name }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# ----------------------------------------------------------------
|
|
330
|
+
# Shared execution core — used by both batch and runtime paths
|
|
331
|
+
# ----------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
# Invoke a tool with retry logic, returning an Ask::Runtime::ToolResult.
|
|
334
|
+
#
|
|
335
|
+
# This is the single source of truth for tool invocation. It wraps
|
|
336
|
+
# try_call and handles retryable errors, producing a normalized
|
|
337
|
+
# ToolResult that both the batch and runtime paths consume.
|
|
338
|
+
def invoke_tool_with_retry(tool, tool_call_id, args, abort_controller = nil)
|
|
339
|
+
@max_retries.times do |attempt|
|
|
340
|
+
if abort_cancelled?(abort_controller)
|
|
341
|
+
return Ask::Runtime::ToolResult.cancelled("Aborted")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
result = try_call(tool, args, abort_controller)
|
|
345
|
+
return result unless result.failure? && retryable_error?(result)
|
|
346
|
+
|
|
347
|
+
sleep((2 ** attempt) * 0.5 + rand(0.0..0.5))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
if abort_cancelled?(abort_controller)
|
|
351
|
+
return Ask::Runtime::ToolResult.cancelled("Aborted")
|
|
352
|
+
end
|
|
353
|
+
try_call(tool, args)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Check if the abort controller signals cancellation.
|
|
357
|
+
# Handles both ToolAbortController (aborted?) and Canceller (cancelled?).
|
|
358
|
+
def abort_cancelled?(controller)
|
|
359
|
+
return false unless controller
|
|
360
|
+
return true if controller.respond_to?(:aborted?) && controller.aborted?
|
|
361
|
+
return true if controller.respond_to?(:cancelled?) && controller.cancelled?
|
|
362
|
+
false
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Invoke a tool once, returning an Ask::Runtime::ToolResult.
|
|
366
|
+
#
|
|
367
|
+
# Normalizes all tool return types (Ask::Result, Hash, String, pending)
|
|
368
|
+
# into the canonical ToolResult shape. Preserves halted metadata for
|
|
369
|
+
# the batch path to consume.
|
|
370
|
+
def try_call(tool, args, abort_controller = nil)
|
|
371
|
+
raw = tool.call(args, abort_controller: abort_controller)
|
|
372
|
+
|
|
373
|
+
if raw.respond_to?(:pending?) && raw.pending?
|
|
374
|
+
return Ask::Runtime::ToolResult.success(data: raw)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
if raw.respond_to?(:ok?)
|
|
378
|
+
if raw.ok?
|
|
379
|
+
# Preserve the Ask::Result as the ToolResult's result object
|
|
380
|
+
# so downstream code can access metadata (e.g. halted flag).
|
|
381
|
+
Ask::Runtime::ToolResult.new(result: raw, outcome: :success)
|
|
382
|
+
else
|
|
383
|
+
Ask::Runtime::ToolResult.failure(extract_error_message(raw))
|
|
384
|
+
end
|
|
385
|
+
else
|
|
386
|
+
Ask::Runtime::ToolResult.success(data: raw)
|
|
387
|
+
end
|
|
388
|
+
rescue => e
|
|
389
|
+
Ask::Runtime::ToolResult.failure("#{e.class}: #{e.message}")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Extract the output payload from an Ask::Result, unwrapping the wrapper
|
|
393
|
+
# so ToolResult.output carries the actual data.
|
|
394
|
+
def unwrap_output(result)
|
|
395
|
+
result.respond_to?(:output) ? result.output : result
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Extract a string error message from a failed Ask::Result.
|
|
399
|
+
def extract_error_message(result)
|
|
400
|
+
msg = result.respond_to?(:error_message) ? result.error_message : nil
|
|
401
|
+
msg || result.to_s
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Check if a ToolResult represents a retryable error.
|
|
405
|
+
def retryable_error?(tool_result)
|
|
406
|
+
return false unless tool_result.failure?
|
|
407
|
+
|
|
408
|
+
error_msg = tool_result.error_message.to_s
|
|
409
|
+
# Extract the class name from "ClassName: message" format.
|
|
410
|
+
# Split on ": " (colon-space) to avoid breaking on :: namespaces.
|
|
411
|
+
error_class_name = error_msg.split(": ").first
|
|
412
|
+
retryable_error_name?(error_class_name)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# ----------------------------------------------------------------
|
|
416
|
+
# Batch path: execute_single_tool (unchanged public behavior)
|
|
417
|
+
# ----------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def execute_single_tool(tool_call, tools, hooks, event_emitter, abort_controller = nil, runtime_event_sink = nil)
|
|
107
420
|
return aborted_result(tool_call) if abort_controller&.aborted?
|
|
108
421
|
|
|
109
422
|
tool = tools.find { |t| t.name == tool_call.name }
|
|
@@ -139,53 +452,101 @@ module Ask
|
|
|
139
452
|
name: tool_call.name, arguments: args, id: tool_call.id
|
|
140
453
|
))
|
|
141
454
|
|
|
455
|
+
runtime_call = build_runtime_tool_call(tool_call, effective_args: args)
|
|
456
|
+
# Expose the pending runtime call before execution starts so tools
|
|
457
|
+
# can observe the full pending → running → terminal lifecycle.
|
|
458
|
+
Thread.current[:ask_agent_runtime_call] = runtime_call
|
|
459
|
+
runtime_context = build_runtime_context(abort_controller)
|
|
460
|
+
|
|
142
461
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
462
|
+
start_timestamp = Time.now
|
|
143
463
|
Thread.current[:ask_agent_tool_call_id] = tool_call.id
|
|
144
|
-
|
|
145
|
-
|
|
464
|
+
Thread.current[:ask_agent_runtime_context] = runtime_context
|
|
465
|
+
|
|
466
|
+
# Transition to :running once execution is active and observable by tools.
|
|
467
|
+
running_call = runtime_call.with(state: :running, started_at: start_timestamp)
|
|
468
|
+
Thread.current[:ask_agent_runtime_call] = running_call
|
|
469
|
+
|
|
470
|
+
# Emit runtime ToolStarted event (one per tool call).
|
|
471
|
+
emit_runtime_event(runtime_event_sink, :tool_started,
|
|
472
|
+
Ask::Runtime::Events::ToolStarted.new(
|
|
473
|
+
tool_call: running_call, execution_context: runtime_context, timestamp: start_timestamp
|
|
474
|
+
))
|
|
475
|
+
|
|
476
|
+
# Shared execution core — invoke tool with retry, get ToolResult.
|
|
477
|
+
tool_result = begin
|
|
478
|
+
invoke_tool_with_retry(tool, tool_call.id, args, abort_controller)
|
|
146
479
|
rescue Exception => e
|
|
147
|
-
{
|
|
148
|
-
ensure
|
|
149
|
-
Thread.current[:ask_agent_tool_call_id] = nil
|
|
480
|
+
Ask::Runtime::ToolResult.failure("#{e.class}: #{e.message}")
|
|
150
481
|
end
|
|
151
|
-
|
|
482
|
+
|
|
483
|
+
# Classify into terminal state + legacy agent result hash.
|
|
484
|
+
terminal_state, legacy_result = classify_tool_result(tool_result, abort_controller)
|
|
485
|
+
|
|
486
|
+
# Transition runtime call to terminal state and attach ToolResult
|
|
487
|
+
# BEFORE clearing thread locals so tools can observe the full lifecycle
|
|
488
|
+
# including the unwrapped ToolResult.output.
|
|
489
|
+
finished_call = running_call.with(
|
|
490
|
+
state: terminal_state,
|
|
491
|
+
tool_result: tool_result,
|
|
492
|
+
error: tool_result.error_message,
|
|
493
|
+
finished_at: Time.now
|
|
494
|
+
)
|
|
495
|
+
Thread.current[:ask_agent_runtime_call] = finished_call
|
|
496
|
+
|
|
497
|
+
# Clear all thread locals — even on errors — so no stale or
|
|
498
|
+
# terminal runtime objects remain in Thread.current after cleanup.
|
|
499
|
+
Thread.current[:ask_agent_tool_call_id] = nil
|
|
500
|
+
Thread.current[:ask_agent_runtime_call] = nil
|
|
501
|
+
Thread.current[:ask_agent_runtime_context] = nil
|
|
502
|
+
|
|
503
|
+
duration_secs = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
504
|
+
duration_ms = (duration_secs * 1000).to_i
|
|
152
505
|
@total_executions += 1
|
|
153
506
|
|
|
507
|
+
# Emit runtime terminal event (exactly one per tool call).
|
|
508
|
+
emit_runtime_terminal_event(
|
|
509
|
+
runtime_event_sink, terminal_state,
|
|
510
|
+
tool_call: finished_call, tool_result: tool_result,
|
|
511
|
+
execution_context: runtime_context, timestamp: Time.now,
|
|
512
|
+
duration: duration_secs
|
|
513
|
+
)
|
|
514
|
+
|
|
154
515
|
return aborted_result(tool_call) if abort_controller&.aborted?
|
|
155
516
|
|
|
156
|
-
hook_result = hooks.run_after_tool(tool_call,
|
|
517
|
+
hook_result = hooks.run_after_tool(tool_call, legacy_result, {})
|
|
157
518
|
if hook_result&.dig(:action) == :transform
|
|
158
|
-
|
|
519
|
+
legacy_result = hook_result[:result]
|
|
159
520
|
end
|
|
160
521
|
|
|
161
|
-
is_error =
|
|
162
|
-
critical = is_error && critical_error?(
|
|
163
|
-
halted =
|
|
522
|
+
is_error = legacy_result[:is_error] == true
|
|
523
|
+
critical = is_error && critical_error?(legacy_result[:error])
|
|
524
|
+
halted = legacy_result[:halted] == true
|
|
164
525
|
|
|
165
526
|
if halted
|
|
166
527
|
abort_controller&.abort!
|
|
167
528
|
end
|
|
168
529
|
|
|
169
530
|
if is_error && @telemetry
|
|
170
|
-
@telemetry.log(:tool_error, session_id: @session_id, tool_name: tool_call.name, error_class:
|
|
531
|
+
@telemetry.log(:tool_error, session_id: @session_id, tool_name: tool_call.name, error_class: legacy_result[:error] || "RuntimeError", error_message: legacy_result[:result].to_s)
|
|
171
532
|
end
|
|
172
533
|
|
|
173
534
|
event_emitter.emit(Events::ToolExecutionEnd.new(
|
|
174
|
-
name: tool_call.name, id: tool_call.id, result:
|
|
535
|
+
name: tool_call.name, id: tool_call.id, result: legacy_result, is_error: is_error, duration_ms: duration_ms
|
|
175
536
|
))
|
|
176
537
|
|
|
177
538
|
message = if is_error
|
|
178
|
-
|
|
179
|
-
error_msg = if
|
|
180
|
-
|
|
181
|
-
elsif
|
|
182
|
-
|
|
539
|
+
inner = legacy_result[:result]
|
|
540
|
+
error_msg = if inner.is_a?(Hash) && inner[:error]
|
|
541
|
+
inner[:error].to_s
|
|
542
|
+
elsif inner.is_a?(String)
|
|
543
|
+
inner
|
|
183
544
|
else
|
|
184
|
-
|
|
545
|
+
legacy_result.to_s
|
|
185
546
|
end
|
|
186
547
|
"Tool #{tool_call.name} error: #{error_msg}"
|
|
187
548
|
else
|
|
188
|
-
|
|
549
|
+
legacy_result[:result].to_s
|
|
189
550
|
end
|
|
190
551
|
|
|
191
552
|
# Large outputs never enter the transcript: store the full message
|
|
@@ -200,8 +561,8 @@ module Ask
|
|
|
200
561
|
# Collect tool-produced deliverables (metadata[:artifact]) into the
|
|
201
562
|
# session's artifact store. Best-effort: a malformed artifact is
|
|
202
563
|
# noted in the message, never a tool failure.
|
|
203
|
-
if @artifact_store &&
|
|
204
|
-
(artifact =
|
|
564
|
+
if @artifact_store && legacy_result[:result].respond_to?(:metadata) &&
|
|
565
|
+
(artifact = legacy_result[:result].metadata[:artifact])
|
|
205
566
|
begin
|
|
206
567
|
attrs = symbolize_artifact(artifact)
|
|
207
568
|
@artifact_store.store(@session_id, **attrs)
|
|
@@ -210,8 +571,8 @@ module Ask
|
|
|
210
571
|
end
|
|
211
572
|
end
|
|
212
573
|
|
|
213
|
-
inner =
|
|
214
|
-
status = if
|
|
574
|
+
inner = legacy_result[:result]
|
|
575
|
+
status = if legacy_result[:is_error] == true
|
|
215
576
|
"error"
|
|
216
577
|
elsif inner.respond_to?(:pending?) && inner.pending?
|
|
217
578
|
"pending"
|
|
@@ -223,42 +584,33 @@ module Ask
|
|
|
223
584
|
tool_name: tool_call.name,
|
|
224
585
|
message: message,
|
|
225
586
|
status: status,
|
|
226
|
-
result:
|
|
587
|
+
result: legacy_result,
|
|
227
588
|
critical_failure: critical,
|
|
228
589
|
halted: halted
|
|
229
590
|
}
|
|
230
591
|
end
|
|
231
592
|
|
|
232
|
-
def
|
|
233
|
-
|
|
234
|
-
return { result: nil, is_error: true, error: "Aborted" } if abort_controller&.aborted?
|
|
235
|
-
|
|
236
|
-
result = try_call(tool, args, abort_controller)
|
|
237
|
-
return result unless result[:is_error] && retryable_error_name?(result[:error])
|
|
593
|
+
def emit_runtime_event(sink, event_type, event)
|
|
594
|
+
return unless sink
|
|
238
595
|
|
|
239
|
-
|
|
240
|
-
|
|
596
|
+
sink.emit(event_type, event: event)
|
|
597
|
+
end
|
|
241
598
|
|
|
242
|
-
|
|
243
|
-
|
|
599
|
+
def emit_runtime_terminal_event(sink, terminal_state, **attrs)
|
|
600
|
+
return unless sink
|
|
601
|
+
|
|
602
|
+
event_class = case terminal_state
|
|
603
|
+
when :completed then Ask::Runtime::Events::ToolCompleted
|
|
604
|
+
when :failed then Ask::Runtime::Events::ToolFailed
|
|
605
|
+
when :cancelled then Ask::Runtime::Events::ToolCancelled
|
|
606
|
+
when :timed_out then Ask::Runtime::Events::ToolTimedOut
|
|
607
|
+
else return
|
|
608
|
+
end
|
|
609
|
+
sink.emit(terminal_event_type(terminal_state), event: event_class.new(**attrs))
|
|
244
610
|
end
|
|
245
611
|
|
|
246
|
-
def
|
|
247
|
-
|
|
248
|
-
is_error = if result.respond_to?(:pending?) && result.pending?
|
|
249
|
-
false # async tool: work continues in the background
|
|
250
|
-
elsif result.respond_to?(:ok?)
|
|
251
|
-
!result.ok?
|
|
252
|
-
else
|
|
253
|
-
false
|
|
254
|
-
end
|
|
255
|
-
hash = { result: result, is_error: is_error }
|
|
256
|
-
if result.respond_to?(:metadata) && result.metadata&.dig(:halted)
|
|
257
|
-
hash[:halted] = true
|
|
258
|
-
end
|
|
259
|
-
hash
|
|
260
|
-
rescue => e
|
|
261
|
-
{ result: e.message, is_error: true, error: e.class.name }
|
|
612
|
+
def terminal_event_type(state)
|
|
613
|
+
"tool_#{state}".to_sym
|
|
262
614
|
end
|
|
263
615
|
|
|
264
616
|
# Store a large tool message in the output store and return a short
|
|
@@ -307,6 +659,103 @@ module Ask
|
|
|
307
659
|
aborted: true
|
|
308
660
|
}
|
|
309
661
|
end
|
|
662
|
+
|
|
663
|
+
# Map a ToolResult to a runtime terminal state and a legacy agent
|
|
664
|
+
# result hash. Returns [terminal_state_symbol, legacy_hash].
|
|
665
|
+
#
|
|
666
|
+
# This is the shared classification logic: the runtime path uses the
|
|
667
|
+
# ToolResult directly; the batch path wraps it in the agent's hash
|
|
668
|
+
# format for backward compatibility.
|
|
669
|
+
def classify_tool_result(tool_result, abort_controller)
|
|
670
|
+
if abort_controller&.aborted?
|
|
671
|
+
return [:cancelled, { result: "Aborted by sibling failure", is_error: true, error: "Aborted" }]
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
halted = tool_halted?(tool_result)
|
|
675
|
+
|
|
676
|
+
case tool_result.outcome
|
|
677
|
+
when :cancelled
|
|
678
|
+
[:cancelled, { result: tool_result.error_message, is_error: true, error: "Aborted" }]
|
|
679
|
+
when :failure
|
|
680
|
+
error_class = extract_error_class(tool_result.error_message)
|
|
681
|
+
[:failed, { result: tool_result.error_message, is_error: true, error: error_class }]
|
|
682
|
+
when :timeout
|
|
683
|
+
[:timed_out, { result: tool_result.error_message, is_error: true, error: "Timeout" }]
|
|
684
|
+
when :success
|
|
685
|
+
if tool_result.output.respond_to?(:pending?) && tool_result.output.pending?
|
|
686
|
+
[:completed, { result: tool_result.output, is_error: false, halted: halted }]
|
|
687
|
+
else
|
|
688
|
+
# Preserve the original Ask::Result in the legacy hash for
|
|
689
|
+
# backward compatibility — tools that return Ask::Result get
|
|
690
|
+
# their result object in [:result][:result].
|
|
691
|
+
legacy_inner = if tool_result.result.is_a?(Ask::Result)
|
|
692
|
+
tool_result.result
|
|
693
|
+
else
|
|
694
|
+
tool_result.output
|
|
695
|
+
end
|
|
696
|
+
[:completed, { result: legacy_inner, is_error: false, halted: halted }]
|
|
697
|
+
end
|
|
698
|
+
else
|
|
699
|
+
[:completed, { result: tool_result.output, is_error: false, halted: halted }]
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Detect if a tool result carries halted metadata.
|
|
704
|
+
# Checks the underlying Ask::Result (ToolResult.result) for halted
|
|
705
|
+
# metadata, since that's where tools set it via Ask::Result.ok(..., metadata: { halted: true }).
|
|
706
|
+
def tool_halted?(tool_result)
|
|
707
|
+
return false unless tool_result.success?
|
|
708
|
+
|
|
709
|
+
inner = tool_result.result
|
|
710
|
+
inner.respond_to?(:metadata) && inner.metadata&.dig(:halted) == true
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Extract the error class name from a tool failure message.
|
|
714
|
+
# Messages from try_call are formatted as "ClassName: message".
|
|
715
|
+
# Split on ": " (colon-space) to avoid breaking on :: namespaces.
|
|
716
|
+
def extract_error_class(error_message)
|
|
717
|
+
return "Error" unless error_message
|
|
718
|
+
error_message.split(": ").first || "Error"
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Legacy classify_result for backward compatibility with code that
|
|
722
|
+
# passes raw agent result hashes. Delegates to the shared
|
|
723
|
+
# classify_tool_result after wrapping the hash in a ToolResult.
|
|
724
|
+
def classify_result(result, abort_controller)
|
|
725
|
+
tool_result = if abort_controller&.aborted?
|
|
726
|
+
Ask::Runtime::ToolResult.cancelled("Aborted by sibling failure")
|
|
727
|
+
elsif result[:is_error] == true
|
|
728
|
+
error_msg = extract_error_from_hash(result)
|
|
729
|
+
Ask::Runtime::ToolResult.failure(error_msg)
|
|
730
|
+
else
|
|
731
|
+
inner = result[:result]
|
|
732
|
+
if inner.respond_to?(:pending?) && inner.pending?
|
|
733
|
+
Ask::Runtime::ToolResult.success(data: inner)
|
|
734
|
+
else
|
|
735
|
+
data = if inner.is_a?(Ask::Result)
|
|
736
|
+
inner.output
|
|
737
|
+
elsif inner.is_a?(Hash) && inner.key?(:result)
|
|
738
|
+
inner[:result]
|
|
739
|
+
else
|
|
740
|
+
inner
|
|
741
|
+
end
|
|
742
|
+
Ask::Runtime::ToolResult.success(data: data)
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
classify_tool_result(tool_result, abort_controller)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def extract_error_from_hash(result)
|
|
750
|
+
inner = result[:result]
|
|
751
|
+
if inner.is_a?(Hash) && inner[:error]
|
|
752
|
+
inner[:error].to_s
|
|
753
|
+
elsif inner.is_a?(String)
|
|
754
|
+
inner
|
|
755
|
+
else
|
|
756
|
+
result.to_s
|
|
757
|
+
end
|
|
758
|
+
end
|
|
310
759
|
end
|
|
311
760
|
end
|
|
312
761
|
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "ask/skills"
|
|
|
8
8
|
require "ask-llm-providers"
|
|
9
9
|
require "ask-tools"
|
|
10
10
|
require "ask-state-providers"
|
|
11
|
+
require "ask-runtime"
|
|
11
12
|
|
|
12
13
|
module Ask
|
|
13
14
|
module Agent
|
|
@@ -258,6 +259,20 @@ module Ask
|
|
|
258
259
|
def current_tool_call_id
|
|
259
260
|
Thread.current[:ask_agent_tool_call_id]
|
|
260
261
|
end
|
|
262
|
+
|
|
263
|
+
# @return [Ask::Runtime::ToolCall, nil] the runtime tool call for the
|
|
264
|
+
# currently executing tool on this thread. Available only during tool
|
|
265
|
+
# execution inside ToolExecutor.
|
|
266
|
+
def current_runtime_call
|
|
267
|
+
Thread.current[:ask_agent_runtime_call]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [Ask::Runtime::ExecutionContext, nil] the runtime execution
|
|
271
|
+
# context for the currently executing tool on this thread. Available
|
|
272
|
+
# only during tool execution inside ToolExecutor.
|
|
273
|
+
def current_runtime_context
|
|
274
|
+
Thread.current[:ask_agent_runtime_context]
|
|
275
|
+
end
|
|
261
276
|
end
|
|
262
277
|
end
|
|
263
278
|
end
|
data/lib/ask-agent.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.40.
|
|
4
|
+
version: 0.40.20
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 0.12.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ask-runtime
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: ask-state-providers
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|