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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46ea77e0ac0feca360164604367729e05f15e22212f479c59ad1954da5e5e924
4
- data.tar.gz: 5a1e0198d2e4d9faf7831bd1c7f8520a82a01c7fff46a9a94512a60a6d285409
3
+ metadata.gz: 8ad845b7f0d67966819af4e0f588f47ff44793e874b56c08dd13208dc3334e5d
4
+ data.tar.gz: 52c5ef1af2a97cb7cb41a7247c19a614e75ca5138f7192ad71ecde7ffdc3eede
5
5
  SHA512:
6
- metadata.gz: d195e1e86df0f281de603cdd607d0b2d88bbbc3d07f4c46b5dbb81235fd102d7f4a19781e625b8ddd8c304babb47eaf60fa894c6513b5e944f493b0e4928c74f
7
- data.tar.gz: c4289b1c74e4ee05de9ffe2c0c266e46599e49c546b0c22c23ea76258a23e291dd5804e3d5cdd0d514375540fc22d7732a920de1e6d303f7dfde0fcbc04ca883
6
+ metadata.gz: '078e71e0cf4fd80c5d08badd68377eb4b73cf0923f46dc6f065bab94d3106323892bb307acf717ea3640907939e5706645b42986c5d1fc003544801e2fece9e0'
7
+ data.tar.gz: '068595e1f29705bcda12462c342b49a4a5a2d055c5f5f75c5c24edbe02bd8475a8803e6f7ecebb0fddd782dac84e168db8f877d6fe6e2eda566d2bb000e15f26'
@@ -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)
@@ -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
@@ -878,10 +878,10 @@ end
878
878
  private
879
879
 
880
880
  def build_audit_log(config)
881
- config ||= Ask::Agent.configuration.audit_log
882
- return nil unless config
883
- Ask::Agent::Policies::AuditLog.new(self, adapter: config)
884
- end
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
- def execute(tool_calls, tools, hooks:, event_emitter:, session_id: nil, result_callback: nil)
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
- sibling_abort = ToolAbortController.new
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
- def execute_single_tool(tool_call, tools, hooks, event_emitter, abort_controller = nil)
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
- result = begin
145
- execute_with_retry(tool, tool_call.id, args, abort_controller)
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
- { result: e.message, is_error: true, error: e.class.name }
148
- ensure
149
- Thread.current[:ask_agent_tool_call_id] = nil
480
+ Ask::Runtime::ToolResult.failure("#{e.class}: #{e.message}")
150
481
  end
151
- duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).to_i
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, result, {})
517
+ hook_result = hooks.run_after_tool(tool_call, legacy_result, {})
157
518
  if hook_result&.dig(:action) == :transform
158
- result = hook_result[:result]
519
+ legacy_result = hook_result[:result]
159
520
  end
160
521
 
161
- is_error = result[:is_error] == true
162
- critical = is_error && critical_error?(result[:error])
163
- halted = result[:halted] == true
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: result[:error] || "RuntimeError", error_message: result[:result].to_s)
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: result, is_error: is_error, duration_ms: duration
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
- tool_result = result[:result]
179
- error_msg = if tool_result.is_a?(Hash) && tool_result[:error]
180
- tool_result[:error].to_s
181
- elsif tool_result.is_a?(String)
182
- tool_result
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
- result.to_s
545
+ legacy_result.to_s
185
546
  end
186
547
  "Tool #{tool_call.name} error: #{error_msg}"
187
548
  else
188
- result[:result].to_s
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 && result[:result].respond_to?(:metadata) &&
204
- (artifact = result[:result].metadata[: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 = result[:result]
214
- status = if result[:is_error] == true
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: result,
587
+ result: legacy_result,
227
588
  critical_failure: critical,
228
589
  halted: halted
229
590
  }
230
591
  end
231
592
 
232
- def execute_with_retry(tool, tool_call_id, args, abort_controller = nil)
233
- @max_retries.times do |attempt|
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
- sleep((2 ** attempt) * 0.5 + rand(0.0..0.5))
240
- end
596
+ sink.emit(event_type, event: event)
597
+ end
241
598
 
242
- return { result: nil, is_error: true, error: "Aborted" } if abort_controller&.aborted?
243
- try_call(tool, args)
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 try_call(tool, args, abort_controller = nil)
247
- result = tool.call(args, abort_controller: abort_controller)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.40.19"
5
+ VERSION = "0.40.20"
6
6
  end
7
7
  end
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
@@ -2,4 +2,5 @@
2
2
 
3
3
  require "ask-llm-providers"
4
4
  require "ask-tools"
5
+ require "ask-runtime"
5
6
  require "ask/agent"
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.19
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