ask-agent 0.25.6 → 0.26.1

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: e64d0487f0c2798ee7515fa7b926747a4994be257a9d5290ee87461cd762018e
4
+ data.tar.gz: 8f5102405021977edee53cbc4bd9e743b4dca0db61362a5e14de346b99590a5b
5
5
  SHA512:
6
- metadata.gz: a3d254d5ff2f50ad781e98b087b54c4d085ff36c360970d64106ed1adcbbea926cf07e1f7603bb3a538d9ae7e72a1f2f781b1f0951510258eced384ab797a09d
7
- data.tar.gz: 7be070f08f92a3c8db2897730bbf833d8942f82c154be0f23b6fd6be28a27764ddc56313c1fce837646eb551786876eaff2d1d7e846ea27da4102704d7e58100
6
+ metadata.gz: 718a912eb746699b9c2f834ff75303d27c5391b1d7c552137d023e5526595f8ea973af00b89927c6984a94b5608ad37dc55c8dd28e15e5dcc083259e9880f6eb
7
+ data.tar.gz: e7e2afb7ab3c2552473191dc7a0c3aacb409c467f3ce721beb0a8bfe2fb5ee47e6c66a3ba72db7e12ae56b632b847ee21e87a884754a2e3210a125353f93a814
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [0.26.1] - 2026-08-05
2
+
3
+ ### Added
4
+
5
+ - **`Session#pending_tools?`** — true while any async tool is still running;
6
+ the voice seam uses it to tell the worker a completion is coming.
7
+
8
+ ## [0.26.0] - 2026-08-05
9
+
10
+ ### Added
11
+
12
+ - **Async tools (pending results).** A tool can return `Ask::Result.pending`
13
+ (ask-core 0.10.0) to hand the turn back with its interim message — the
14
+ agent voices it immediately and keeps talking — while the real work runs
15
+ in the background. The tool reads `Ask::Agent.current_session` and
16
+ `Ask::Agent.current_tool_call_id` (thread-locals set during the run and
17
+ the tool call) and completes later via
18
+ `Session#complete_pending_tool(tool_call_id:, result:)`, which adds the
19
+ tool message to the conversation and runs a follow-up turn so the agent
20
+ voices the answer. Completions that land mid-turn queue until the turn
21
+ ends. `ToolPending`/`ToolCompleted` events are emitted; the loop stops
22
+ after a pending call (no recursion, no loop detection, no premature
23
+ chat message).
24
+
1
25
  ## [0.25.6] - 2026-08-05
2
26
 
3
27
  ### 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,64 @@ 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
+ # @return [Boolean] true while at least one async tool is running
340
+ def pending_tools?
341
+ @pending_mutex.synchronize { !@pending_tools.empty? }
342
+ end
343
+
344
+ # Completes a pending (async) tool call from a background thread.
345
+ #
346
+ # Adds the tool result to the conversation and, when the session is
347
+ # idle, runs a follow-up turn so the agent voices the answer. If a
348
+ # turn is running, the follow-up fires as soon as it ends.
349
+ #
350
+ # @param tool_call_id [String] the original tool call id
351
+ # @param result [Hash] tool result hash ({message:, is_error:, ...})
352
+ # @return [Boolean] true if the completion was registered
353
+ def complete_pending_tool(tool_call_id:, result:)
354
+ follow_up = @pending_mutex.synchronize do
355
+ pending = @pending_tools.delete(tool_call_id)
356
+ return false unless pending
357
+
358
+ @chat.add_message(
359
+ role: :tool,
360
+ content: result[:message].to_s,
361
+ tool_call_id: tool_call_id
362
+ )
363
+ if @running
364
+ @followup_pending = true
365
+ false
366
+ else
367
+ true
368
+ end
369
+ end
370
+ emit(Events::ToolCompleted.new(name: result[:tool_name], id: tool_call_id, result: result))
371
+ run_follow_up if follow_up
372
+ true
373
+ end
374
+
375
+ # A follow-up turn driven by an async completion: runs the loop with
376
+ # the tool message already in the conversation, preserving turn state.
377
+ def run_follow_up
378
+ run("", reset: false)
379
+ rescue => e
380
+ emit(Events::Error.new(error: e.message, recoverable: false))
381
+ raise
382
+ end
383
+
311
384
  # Load a skill by name or file path.
312
385
  # Injects the skill's full instructions into the conversation as a system message.
313
386
  #
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto