ask-agent 0.25.6 → 0.26.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0ae07fe535ce2c8f33a24f145440bee62e70869d1d1f5cd0eb0cf71cb4b4e2f
4
- data.tar.gz: ecb0c237a7b068f854547081b58c659e9b6735cc8d1d1c3f00936f3e0d24037e
3
+ metadata.gz: d32526c4ac8d8daa60b4f4b63e5787617c2070154cb854f6fff61222f880185d
4
+ data.tar.gz: cd528117e7577374e8c41a423ec72c9fd1560980017f968d215f674b1a916e3c
5
5
  SHA512:
6
- metadata.gz: a3d254d5ff2f50ad781e98b087b54c4d085ff36c360970d64106ed1adcbbea926cf07e1f7603bb3a538d9ae7e72a1f2f781b1f0951510258eced384ab797a09d
7
- data.tar.gz: 7be070f08f92a3c8db2897730bbf833d8942f82c154be0f23b6fd6be28a27764ddc56313c1fce837646eb551786876eaff2d1d7e846ea27da4102704d7e58100
6
+ metadata.gz: 4c210ccc2a08d0ed0d2bb60a560aac06635633c816bfd4d5b9ae21b2139af3ae5729b37fb46b795e48b0bbc2975c33e855cea695b507ad306ce04187330605a7
7
+ data.tar.gz: fb3954229781a8e34a517a0e8d81369e55579ae4c18f041ffb4c99b3295c628bb0e5b49d9f08d080b4cd6d556614ddb5b5cac685b9c7d5813bfb0371281d1e54
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.26.0] - 2026-08-05
2
+
3
+ ### Added
4
+
5
+ - **Async tools (pending results).** A tool can return `Ask::Result.pending`
6
+ (ask-core 0.10.0) to hand the turn back with its interim message — the
7
+ agent voices it immediately and keeps talking — while the real work runs
8
+ in the background. The tool reads `Ask::Agent.current_session` and
9
+ `Ask::Agent.current_tool_call_id` (thread-locals set during the run and
10
+ the tool call) and completes later via
11
+ `Session#complete_pending_tool(tool_call_id:, result:)`, which adds the
12
+ tool message to the conversation and runs a follow-up turn so the agent
13
+ voices the answer. Completions that land mid-turn queue until the turn
14
+ ends. `ToolPending`/`ToolCompleted` events are emitted; the loop stops
15
+ after a pending call (no recursion, no loop detection, no premature
16
+ chat message).
17
+
1
18
  ## [0.25.6] - 2026-08-05
2
19
 
3
20
  ### Added
@@ -16,6 +16,10 @@ module Ask
16
16
  MessageEnd = Data.define(:tool_calls)
17
17
 
18
18
  ToolExecutionStart = Data.define(:name, :arguments, :id)
19
+ # Emitted when a tool returns Ask::Result.pending (async work started).
20
+ ToolPending = Data.define(:name, :id)
21
+ # Emitted when a pending (async) tool's background work completes.
22
+ ToolCompleted = Data.define(:name, :id, :result)
19
23
  ToolExecutionUpdate = Data.define(:name, :id, :partial_result)
20
24
  ToolExecutionEnd = Data.define(:name, :id, :result, :is_error, :duration_ms)
21
25
 
@@ -91,16 +91,37 @@ module Ask
91
91
  # in threads (with the caller's thread-local context inherited);
92
92
  # sequential tools run in the caller thread so per-request context
93
93
  # (e.g. Rails CurrentAttributes) is visible without any copying.
94
+ # Pending (async) tool results skip the chat message — it is added
95
+ # when the background work completes.
94
96
  user_results = tool_executor.execute(
95
97
  user_tool_calls, tools, hooks: hooks, event_emitter: event_emitter,
96
98
  result_callback: lambda do |tool_call_id, result|
97
99
  tc = user_tool_calls[tool_call_id]
100
+ next unless tc
101
+ next if result[:status] == "pending"
102
+
98
103
  chat.add_message(role: :tool, content: result[:message].to_s, tool_call_id: tool_call_id) if tc
99
104
  end
100
105
  )
101
106
  all_tool_results.concat(user_results)
102
107
  end
103
108
 
109
+ # Async tools: hand the turn back with the interim reply. The
110
+ # session registers the pending calls; completions arrive later via
111
+ # Session#complete_pending_tool.
112
+ pending_calls = all_tool_results.select { |r| r[:status] == "pending" }
113
+ unless pending_calls.empty?
114
+ pending_calls.each do |pending_result|
115
+ if event_emitter.respond_to?(:register_pending_tool)
116
+ event_emitter.register_pending_tool(
117
+ pending_result[:tool_call_id], pending_result
118
+ )
119
+ end
120
+ end
121
+ @consecutive_tool_turns = 0
122
+ return response.content.to_s
123
+ end
124
+
104
125
  # Check loop detection
105
126
  if loop_detected?(all_tool_results)
106
127
  raise LoopDetected, all_tool_results.last[:tool_name]
@@ -33,6 +33,9 @@ module Ask
33
33
  @running = false
34
34
  @deleted = false
35
35
  @abort_requested = false
36
+ @pending_tools = {}
37
+ @pending_mutex = Mutex.new
38
+ @followup_pending = false
36
39
  @turn_count = 0
37
40
  @created_at = Time.now
38
41
  @_no_tools_instructed = false
@@ -85,14 +88,17 @@ module Ask
85
88
  end
86
89
  end
87
90
 
88
- def run(message, tools: nil)
91
+ def run(message, tools: nil, reset: true)
89
92
  raise "Session deleted" if @deleted
90
93
  raise "Session already running" if @running
91
94
 
92
95
  @running = true
93
96
  @abort_requested = false
94
- @turn_count = 0
95
- @loop.reset!
97
+ Ask::Agent.current_session = self
98
+ if reset
99
+ @turn_count = 0
100
+ @loop.reset!
101
+ end
96
102
 
97
103
  emit(Events::SessionStart.new)
98
104
 
@@ -140,7 +146,16 @@ module Ask
140
146
  raise
141
147
  ensure
142
148
  @running = false
149
+ Ask::Agent.current_session = nil if Ask::Agent.current_session.equal?(self)
143
150
  persist! if @state
151
+ # A pending tool completed while this run was busy: voice the
152
+ # result now that the turn is over (one follow-up per completion).
153
+ follow_up = @pending_mutex.synchronize do
154
+ take = @followup_pending
155
+ @followup_pending = false
156
+ take
157
+ end
158
+ run_follow_up if follow_up
144
159
  end
145
160
 
146
161
  @tool_calls_made = @tool_executor.total_executions
@@ -308,6 +323,59 @@ module Ask
308
323
 
309
324
  def abort_requested? = @abort_requested
310
325
 
326
+ # --- Async (pending) tools ---
327
+
328
+ # Registers a pending tool call (called by the loop when a tool
329
+ # returned Ask::Result.pending). The background work completes later
330
+ # via #complete_pending_tool.
331
+ def register_pending_tool(tool_call_id, result)
332
+ @pending_mutex.synchronize do
333
+ @pending_tools[tool_call_id] = result
334
+ end
335
+ emit(Events::ToolPending.new(name: result[:tool_name], id: tool_call_id))
336
+ nil
337
+ end
338
+
339
+ # Completes a pending (async) tool call from a background thread.
340
+ #
341
+ # Adds the tool result to the conversation and, when the session is
342
+ # idle, runs a follow-up turn so the agent voices the answer. If a
343
+ # turn is running, the follow-up fires as soon as it ends.
344
+ #
345
+ # @param tool_call_id [String] the original tool call id
346
+ # @param result [Hash] tool result hash ({message:, is_error:, ...})
347
+ # @return [Boolean] true if the completion was registered
348
+ def complete_pending_tool(tool_call_id:, result:)
349
+ follow_up = @pending_mutex.synchronize do
350
+ pending = @pending_tools.delete(tool_call_id)
351
+ return false unless pending
352
+
353
+ @chat.add_message(
354
+ role: :tool,
355
+ content: result[:message].to_s,
356
+ tool_call_id: tool_call_id
357
+ )
358
+ if @running
359
+ @followup_pending = true
360
+ false
361
+ else
362
+ true
363
+ end
364
+ end
365
+ emit(Events::ToolCompleted.new(name: result[:tool_name], id: tool_call_id, result: result))
366
+ run_follow_up if follow_up
367
+ true
368
+ end
369
+
370
+ # A follow-up turn driven by an async completion: runs the loop with
371
+ # the tool message already in the conversation, preserving turn state.
372
+ def run_follow_up
373
+ run("", reset: false)
374
+ rescue => e
375
+ emit(Events::Error.new(error: e.message, recoverable: false))
376
+ raise
377
+ end
378
+
311
379
  # Load a skill by name or file path.
312
380
  # Injects the skill's full instructions into the conversation as a system message.
313
381
  #
@@ -81,7 +81,7 @@ module Ask
81
81
  end
82
82
 
83
83
  threads.each(&:join)
84
- tool_calls.keys.map { |id| results[id] }.compact
84
+ tool_calls.keys.filter_map { |id| results[id]&.merge(tool_call_id: id) }
85
85
  end
86
86
 
87
87
  def execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
@@ -90,7 +90,7 @@ module Ask
90
90
  break if sibling_abort.aborted?
91
91
 
92
92
  result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
93
- results << result
93
+ results << result.merge(tool_call_id: id)
94
94
  result_callback&.call(id, result)
95
95
  break if result[:critical_failure]
96
96
  break if result[:halted]
@@ -126,10 +126,13 @@ module Ask
126
126
  ))
127
127
 
128
128
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
+ Thread.current[:ask_agent_tool_call_id] = tool_call.id
129
130
  result = begin
130
131
  execute_with_retry(tool, tool_call.id, args, abort_controller)
131
132
  rescue Exception => e
132
133
  { result: e.message, is_error: true, error: e.class.name }
134
+ ensure
135
+ Thread.current[:ask_agent_tool_call_id] = nil
133
136
  end
134
137
  duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).to_i
135
138
  @total_executions += 1
@@ -171,10 +174,19 @@ module Ask
171
174
  result[:result].to_s
172
175
  end
173
176
 
177
+ inner = result[:result]
178
+ status = if result[:is_error] == true
179
+ "error"
180
+ elsif inner.respond_to?(:pending?) && inner.pending?
181
+ "pending"
182
+ else
183
+ "success"
184
+ end
185
+
174
186
  {
175
187
  tool_name: tool_call.name,
176
188
  message: message,
177
- status: is_error ? "error" : "success",
189
+ status: status,
178
190
  result: result,
179
191
  critical_failure: critical,
180
192
  halted: halted
@@ -197,7 +209,13 @@ module Ask
197
209
 
198
210
  def try_call(tool, args, abort_controller = nil)
199
211
  result = tool.call(args, abort_controller: abort_controller)
200
- is_error = result.respond_to?(:ok?) ? !result.ok? : false
212
+ is_error = if result.respond_to?(:pending?) && result.pending?
213
+ false # async tool: work continues in the background
214
+ elsif result.respond_to?(:ok?)
215
+ !result.ok?
216
+ else
217
+ false
218
+ end
201
219
  hash = { result: result, is_error: is_error }
202
220
  if result.respond_to?(:metadata) && result.metadata&.dig(:halted)
203
221
  hash[:halted] = true
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.25.6"
5
+ VERSION = "0.26.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -270,6 +270,33 @@ require_relative "agent/sub_agent"
270
270
  # Test helpers (loaded on demand)
271
271
  autoload :Test, "ask/agent/test"
272
272
 
273
+ # The session running on this thread (set during Session#run). Async
274
+ # tools read it to complete their background work:
275
+ #
276
+ # session = Ask::Agent.current_session
277
+ # call_id = Ask::Agent.current_tool_call_id
278
+ # Thread.new { ...; session.complete_pending_tool(tool_call_id: call_id, result: {...}) }
279
+ module Ask
280
+ module Agent
281
+ class << self
282
+ # @return [Ask::Agent::Session, nil] the session running on this thread
283
+ def current_session
284
+ Thread.current[:ask_agent_current_session]
285
+ end
286
+
287
+ # @param session [Ask::Agent::Session, nil]
288
+ def current_session=(session)
289
+ Thread.current[:ask_agent_current_session] = session
290
+ end
291
+
292
+ # @return [String, nil] the tool call id being executed on this thread
293
+ def current_tool_call_id
294
+ Thread.current[:ask_agent_tool_call_id]
295
+ end
296
+ end
297
+ end
298
+ end
299
+
273
300
  # Convenience method on the top-level Ask module.
274
301
  # Provides a quick one-shot chat without instantiating a Session directly.
275
302
  #
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.25.6
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto