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 +4 -4
- data/CHANGELOG.md +17 -0
- data/lib/ask/agent/events.rb +4 -0
- data/lib/ask/agent/loop.rb +21 -0
- data/lib/ask/agent/session.rb +71 -3
- data/lib/ask/agent/tool_executor.rb +22 -4
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +27 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d32526c4ac8d8daa60b4f4b63e5787617c2070154cb854f6fff61222f880185d
|
|
4
|
+
data.tar.gz: cd528117e7577374e8c41a423ec72c9fd1560980017f968d215f674b1a916e3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/agent/events.rb
CHANGED
|
@@ -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
|
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -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]
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
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.
|
|
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:
|
|
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?(:
|
|
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
|
data/lib/ask/agent/version.rb
CHANGED
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
|
#
|