ask-agent 0.26.0 → 0.27.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 +53 -0
- data/lib/ask/agent/approval_queue.rb +229 -0
- data/lib/ask/agent/extensions/approval_policy.rb +96 -0
- data/lib/ask/agent/session.rb +98 -1
- data/lib/ask/agent/tool_executor.rb +11 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f67174ee80499aad6a21ff621f02c341daf0e992392996d577218aec2d8987ab
|
|
4
|
+
data.tar.gz: 25b21cc738b1e106730330b3ec28b118545f96a296898e4e9b3e337b4a2bf9a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74fdca8fecf20a3707bf162a319a0b4fa910f3718745fa8276235200e66e98ddd9a005cd7d4ce4bb5fc7d74321a1a86983934cc17041e6a20e58bce48156ce75
|
|
7
|
+
data.tar.gz: 78250a88b07fe2265e276ab5f156dd1c712315585eb906d81a198825c63450cf470ec48702bfbab2ac4b4d757523ff53a86d28646293e21809891626af4616c7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
+
## [0.27.0] — 2026-08-06
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Human-in-the-loop tool approval — `Ask::Agent::ApprovalQueue`.** Tools
|
|
6
|
+
declared `approval_required` (ask-tools) are queued instead of executed:
|
|
7
|
+
the agent receives a pending result and continues, and the tool runs only
|
|
8
|
+
after a human approves it. Built on the async-tools seam
|
|
9
|
+
(`Ask::Result.pending` → `register_pending_tool` → `complete_pending_tool`).
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
session = Ask::Agent::Session.new(
|
|
13
|
+
model: "gpt-4o",
|
|
14
|
+
tools: [SendEmail], # SendEmail.approval_required true
|
|
15
|
+
approval: { auto_approve: {} }
|
|
16
|
+
)
|
|
17
|
+
session.run("Email bob about the launch")
|
|
18
|
+
|
|
19
|
+
# Later, when the user decides:
|
|
20
|
+
session.approval_queue.pending_actions # inspect what's waiting
|
|
21
|
+
session.approval_queue.approve_all # or approve(id) / reject(id)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `approval: true` enables with defaults; a Hash accepts
|
|
25
|
+
`require_approval:` (tool names / regexps / `:all`) and `auto_approve:`
|
|
26
|
+
(user-enabled rules keyed by tool name). A custom `ApprovalQueue`
|
|
27
|
+
instance is accepted too.
|
|
28
|
+
- **Auto-approval is a dual signal** — a tool marked `auto_approvable`
|
|
29
|
+
AND a user rule enabling it. Nothing is silently applied past a manual
|
|
30
|
+
(non-auto-approvable) gate; eligible actions drain in id order with a
|
|
31
|
+
single-flight guard (no double-apply).
|
|
32
|
+
- **Approving executes the real tool** and feeds the result into the
|
|
33
|
+
conversation; **rejecting** injects a "rejected by the user" message and
|
|
34
|
+
the agent adapts. Failed applies leave the action pending for retry.
|
|
35
|
+
- `Session#approval_queue` returns the queue (nil when approval is off).
|
|
36
|
+
|
|
37
|
+
- **`Ask::Agent::Extensions::ApprovalPolicy`** — the classification hook.
|
|
38
|
+
Queues calls for tools whose class declares `approval_required`, or whose
|
|
39
|
+
name matches rule-based lists, or (with `require_approval: :all`) every
|
|
40
|
+
call. Usable standalone as a `before_tool` hook.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- `ToolExecutor` before-tool hooks now support a `:pending` action alongside
|
|
45
|
+
`:block` and `:short_circuit`, returning a pending tool result.
|
|
46
|
+
|
|
47
|
+
## [0.26.1] - 2026-08-05
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **`Session#pending_tools?`** — true while any async tool is still running;
|
|
52
|
+
the voice seam uses it to tell the worker a completion is coming.
|
|
53
|
+
|
|
1
54
|
## [0.26.0] - 2026-08-05
|
|
2
55
|
|
|
3
56
|
### Added
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
# A queue of tool actions awaiting human approval.
|
|
6
|
+
#
|
|
7
|
+
# When an agent calls a tool that requires approval, the action is queued
|
|
8
|
+
# instead of executed — the agent gets a pending result and continues.
|
|
9
|
+
# The user approves or rejects actions later, in bulk or one-by-one.
|
|
10
|
+
#
|
|
11
|
+
# queue = Ask::Agent::ApprovalQueue.new
|
|
12
|
+
# id = queue.submit(tool_call_id: "call_1", tool_name: "send_email",
|
|
13
|
+
# args: { "to" => "x@y.com" }, auto_approvable: false)
|
|
14
|
+
# queue.pending_actions # => [{id: 1, tool_name: "send_email", ...}]
|
|
15
|
+
# queue.approve(1) # applies action 1 via the on_approve callback
|
|
16
|
+
# queue.reject(1)
|
|
17
|
+
#
|
|
18
|
+
# Auto-approval is a dual signal: the action must be marked
|
|
19
|
+
# +auto_approvable+ (by the tool) AND the queue must have a matching
|
|
20
|
+
# auto-approval rule enabled by the user. Otherwise the action queues for
|
|
21
|
+
# human review.
|
|
22
|
+
#
|
|
23
|
+
# Actions are applied in id order and the drainer never applies past a
|
|
24
|
+
# non-auto-approvable (manual) gate — nothing is silently approved.
|
|
25
|
+
class ApprovalQueue
|
|
26
|
+
# One queued action.
|
|
27
|
+
#
|
|
28
|
+
# @!attribute [r] id
|
|
29
|
+
# @return [Integer] sequential id assigned by the queue
|
|
30
|
+
# @!attribute [r] tool_call_id
|
|
31
|
+
# @return [String] the original tool call id from the LLM
|
|
32
|
+
# @!attribute [r] tool_name
|
|
33
|
+
# @return [String] tool name
|
|
34
|
+
# @!attribute [r] args
|
|
35
|
+
# @return [Hash] arguments to pass to the tool when applied
|
|
36
|
+
# @!attribute [r] auto_approvable
|
|
37
|
+
# @return [Boolean] per-action verdict (tool's declaration)
|
|
38
|
+
# @!attribute [r] status
|
|
39
|
+
# @return [Symbol] :pending, :applying, :approved, :rejected
|
|
40
|
+
# @!attribute [r] submitted_at
|
|
41
|
+
# @return [Time]
|
|
42
|
+
# @!attribute [r] message
|
|
43
|
+
# @return [String, nil] human-readable description of the action
|
|
44
|
+
Action = Data.define(:id, :tool_call_id, :tool_name, :args,
|
|
45
|
+
:auto_approvable, :status, :submitted_at, :message)
|
|
46
|
+
|
|
47
|
+
# Create a new approval queue.
|
|
48
|
+
#
|
|
49
|
+
# @param on_approve [Proc, nil] called with an {Action} when it is
|
|
50
|
+
# approved and applied. The session wires this to execute the real
|
|
51
|
+
# tool call.
|
|
52
|
+
# @param on_reject [Proc, nil] called with an {Action} when it is
|
|
53
|
+
# rejected. The session wires this to notify the conversation.
|
|
54
|
+
# @param auto_approve [Hash{String => Boolean}, nil] user-enabled
|
|
55
|
+
# auto-approval rules keyed by tool name. An action is auto-applied
|
|
56
|
+
# only when its tool is listed here with +true+ AND the action itself
|
|
57
|
+
# is marked auto_approvable.
|
|
58
|
+
def initialize(on_approve: nil, on_reject: nil, auto_approve: nil)
|
|
59
|
+
@on_approve = on_approve
|
|
60
|
+
@on_reject = on_reject
|
|
61
|
+
@auto_approve = auto_approve || {}
|
|
62
|
+
@actions = {}
|
|
63
|
+
@next_id = 1
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
@draining = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Queue a tool action for approval.
|
|
69
|
+
#
|
|
70
|
+
# @param tool_call_id [String] original tool call id from the LLM
|
|
71
|
+
# @param tool_name [String] tool name
|
|
72
|
+
# @param args [Hash] arguments for the tool
|
|
73
|
+
# @param auto_approvable [Boolean] whether the tool permits auto-approval
|
|
74
|
+
# @param message [String, nil] human-readable description
|
|
75
|
+
# @return [Integer] the action id
|
|
76
|
+
def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
|
|
77
|
+
action = @mutex.synchronize do
|
|
78
|
+
action = Action.new(
|
|
79
|
+
id: @next_id,
|
|
80
|
+
tool_call_id: tool_call_id,
|
|
81
|
+
tool_name: tool_name,
|
|
82
|
+
args: args || {},
|
|
83
|
+
auto_approvable: auto_approvable,
|
|
84
|
+
status: :pending,
|
|
85
|
+
submitted_at: Time.now,
|
|
86
|
+
message: message
|
|
87
|
+
)
|
|
88
|
+
@next_id += 1
|
|
89
|
+
@actions[action.id] = action
|
|
90
|
+
action
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
drain
|
|
94
|
+
action.id
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# All actions still awaiting a decision, in id order.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Action>]
|
|
100
|
+
def pending_actions
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
@actions.values.select { |a| a.status == :pending }.sort_by(&:id)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param id [Integer]
|
|
107
|
+
# @return [Boolean] whether the action is still awaiting a decision
|
|
108
|
+
def pending?(id)
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
a = @actions[id]
|
|
111
|
+
a && a.status == :pending
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Boolean] true while at least one action awaits a decision
|
|
116
|
+
def any_pending?
|
|
117
|
+
pending_actions.any?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Look up an action by id.
|
|
121
|
+
#
|
|
122
|
+
# @param id [Integer]
|
|
123
|
+
# @return [Action, nil]
|
|
124
|
+
def [](id)
|
|
125
|
+
@mutex.synchronize { @actions[id] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Approve specific actions (by id), applying them in id order.
|
|
129
|
+
#
|
|
130
|
+
# @param ids [Array<Integer>]
|
|
131
|
+
# @return [Array<Action>] the actions that were approved
|
|
132
|
+
def approve(*ids)
|
|
133
|
+
actions = ids.flatten.filter_map { |id| @mutex.synchronize { @actions[id] } }
|
|
134
|
+
actions.select! { |a| a.status == :pending }
|
|
135
|
+
actions.sort_by!(&:id)
|
|
136
|
+
actions.each { |a| apply(a) }
|
|
137
|
+
actions
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Reject specific actions (by id).
|
|
141
|
+
#
|
|
142
|
+
# @param ids [Array<Integer>]
|
|
143
|
+
# @return [Array<Action>] the actions that were rejected
|
|
144
|
+
def reject(*ids)
|
|
145
|
+
actions = ids.flatten.filter_map { |id| @mutex.synchronize { @actions[id] } }
|
|
146
|
+
actions.select! { |a| a.status == :pending }
|
|
147
|
+
actions.sort_by!(&:id)
|
|
148
|
+
actions.each { |a| reject_action(a) }
|
|
149
|
+
actions
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Approve all currently pending actions, in id order.
|
|
153
|
+
#
|
|
154
|
+
# @return [Array<Action>]
|
|
155
|
+
def approve_all
|
|
156
|
+
approve(*pending_actions.map(&:id))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Reject all currently pending actions, in id order.
|
|
160
|
+
#
|
|
161
|
+
# @return [Array<Action>]
|
|
162
|
+
def reject_all
|
|
163
|
+
reject(*pending_actions.map(&:id))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Apply eligible pending actions in id order, stopping at the first
|
|
169
|
+
# action that is NOT auto-eligible (a manual gate) — nothing is
|
|
170
|
+
# silently applied past a human review point. Single-flight guard so
|
|
171
|
+
# concurrent drains cannot double-apply.
|
|
172
|
+
def drain
|
|
173
|
+
return if @mutex.synchronize { @draining }
|
|
174
|
+
@mutex.synchronize { @draining = true }
|
|
175
|
+
|
|
176
|
+
begin
|
|
177
|
+
loop do
|
|
178
|
+
action = @mutex.synchronize do
|
|
179
|
+
@actions.values.select { |a| a.status == :pending }
|
|
180
|
+
.sort_by(&:id)
|
|
181
|
+
.first
|
|
182
|
+
end
|
|
183
|
+
break unless action
|
|
184
|
+
|
|
185
|
+
if auto_approvable?(action)
|
|
186
|
+
apply(action)
|
|
187
|
+
else
|
|
188
|
+
break # manual gate — stop, never skip ahead
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
ensure
|
|
192
|
+
@mutex.synchronize { @draining = false }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def auto_approvable?(action)
|
|
197
|
+
action.auto_approvable && @auto_approve[action.tool_name] == true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def apply(action)
|
|
201
|
+
claimed = @mutex.synchronize do
|
|
202
|
+
return false unless action.status == :pending
|
|
203
|
+
@actions[action.id] = action.with(status: :applying)
|
|
204
|
+
end
|
|
205
|
+
@on_approve&.call(claimed)
|
|
206
|
+
@mutex.synchronize do
|
|
207
|
+
@actions[action.id] = claimed.with(status: :approved)
|
|
208
|
+
end
|
|
209
|
+
true
|
|
210
|
+
rescue StandardError
|
|
211
|
+
# Failed apply: leave the action pending so the user can retry or
|
|
212
|
+
# reject it. Re-raise so the caller (approve/drain) surfaces it.
|
|
213
|
+
@mutex.synchronize do
|
|
214
|
+
@actions[action.id] = action.with(status: :pending) if @actions[action.id]&.status == :applying
|
|
215
|
+
end
|
|
216
|
+
raise
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def reject_action(action)
|
|
220
|
+
return false unless @mutex.synchronize { action.status == :pending }
|
|
221
|
+
@on_reject&.call(action)
|
|
222
|
+
@mutex.synchronize do
|
|
223
|
+
@actions[action.id] = action.with(status: :rejected)
|
|
224
|
+
end
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Extensions
|
|
6
|
+
# Approval policy hook: classifies tool calls as approval-required and
|
|
7
|
+
# routes them into an {Ask::Agent::ApprovalQueue}.
|
|
8
|
+
#
|
|
9
|
+
# Wire it as a +before_tool+ hook on a Session (or enable it with
|
|
10
|
+
# `Session.new(approval: true)`), and any tool whose class declares
|
|
11
|
+
# `approval_required true` — or whose name matches the policy's
|
|
12
|
+
# rule-based lists — is queued for human approval instead of executed.
|
|
13
|
+
# The agent receives a pending result and continues; the tool runs only
|
|
14
|
+
# after a human approves it.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# queue = Ask::Agent::ApprovalQueue.new
|
|
18
|
+
# policy = Ask::Agent::Extensions::ApprovalPolicy.new(queue: queue)
|
|
19
|
+
# session = Ask::Agent::Session.new(
|
|
20
|
+
# model: "gpt-4o",
|
|
21
|
+
# tools: [SendEmail],
|
|
22
|
+
# hooks: { before_tool: [policy.method(:before_tool_call)] }
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# # Later, when the user decides:
|
|
26
|
+
# session.approval_queue.approve_all
|
|
27
|
+
class ApprovalPolicy
|
|
28
|
+
# @param queue [Ask::Agent::ApprovalQueue] the queue actions go into.
|
|
29
|
+
# The queue owns the user-enabled auto-approval rules; this policy
|
|
30
|
+
# only reports each tool's own declaration.
|
|
31
|
+
# @param require_approval [Array<String, Regexp>, :all, nil] extra
|
|
32
|
+
# rule-based classification on top of the tool's own declaration.
|
|
33
|
+
# Strings match tool names exactly, Regexps match against the name,
|
|
34
|
+
# and :all requires approval for every tool call.
|
|
35
|
+
# @param tools [Array<Object>, nil] the session's resolved tool
|
|
36
|
+
# instances, used to read class-level declarations
|
|
37
|
+
# (`approval_required`, `auto_approvable`). When nil, only the
|
|
38
|
+
# rule-based lists classify calls.
|
|
39
|
+
def initialize(queue:, require_approval: nil, tools: nil)
|
|
40
|
+
@queue = queue
|
|
41
|
+
@require_approval = require_approval
|
|
42
|
+
@tools = Array(tools)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Hook entry point — matches the +before_tool+ hook signature.
|
|
46
|
+
#
|
|
47
|
+
# @param tool_call [Ask::Agent::ToolCallInfo]
|
|
48
|
+
# @param _context [Hash]
|
|
49
|
+
# @return [Hash] {action: :proceed} to run, or
|
|
50
|
+
# {action: :pending, action_id:, reason:} to queue for approval
|
|
51
|
+
def before_tool_call(tool_call, _context)
|
|
52
|
+
return { action: :proceed } unless approval_required?(tool_call.name)
|
|
53
|
+
|
|
54
|
+
auto_approvable = tool_auto_approvable?(tool_call.name)
|
|
55
|
+
action_id = @queue.submit(
|
|
56
|
+
tool_call_id: tool_call.id,
|
|
57
|
+
tool_name: tool_call.name,
|
|
58
|
+
args: tool_call.arguments,
|
|
59
|
+
auto_approvable: auto_approvable,
|
|
60
|
+
message: "Calling \"#{tool_call.name}\" requires approval"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
{ action: :pending, action_id: action_id, reason: "Tool '#{tool_call.name}' requires approval" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def approval_required?(tool_name)
|
|
69
|
+
return true if @require_approval == :all
|
|
70
|
+
|
|
71
|
+
rule_matches?(@require_approval, tool_name) || tool_declares_approval?(tool_name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rule_matches?(rules, tool_name)
|
|
75
|
+
Array(rules).any? do |rule|
|
|
76
|
+
rule == tool_name || (rule.is_a?(Regexp) && rule.match?(tool_name))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tool_declares_approval?(tool_name)
|
|
81
|
+
tool = tool_for(tool_name)
|
|
82
|
+
tool&.respond_to?(:approval_required?) && tool.approval_required?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tool_auto_approvable?(tool_name)
|
|
86
|
+
tool = tool_for(tool_name)
|
|
87
|
+
tool&.respond_to?(:auto_approvable?) && tool.auto_approvable?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tool_for(tool_name)
|
|
91
|
+
@tools.find { |t| t.respond_to?(:name) && t.name == tool_name }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Ask
|
|
|
22
22
|
id: nil, system_prompt: nil, parallel_tools: true,
|
|
23
23
|
reflector: nil, telemetry: true, meta_agent: nil,
|
|
24
24
|
agent_dir: nil, evaluator: nil, audit_log: nil,
|
|
25
|
-
skills_disclosure: true, **chat_options)
|
|
25
|
+
skills_disclosure: true, approval: nil, **chat_options)
|
|
26
26
|
@id = id || SecureRandom.uuid
|
|
27
27
|
@agent_dir = agent_dir
|
|
28
28
|
@max_turns = max_turns
|
|
@@ -53,6 +53,7 @@ module Ask
|
|
|
53
53
|
@compactor = compactor ? build_compactor(compactor) : nil
|
|
54
54
|
@hooks = Hooks.new(hooks)
|
|
55
55
|
@audit_log = build_audit_log(audit_log)
|
|
56
|
+
@approval_queue = build_approval(approval)
|
|
56
57
|
|
|
57
58
|
@system_context = build_system_context(system_prompt)
|
|
58
59
|
apply_system_context
|
|
@@ -88,6 +89,13 @@ module Ask
|
|
|
88
89
|
end
|
|
89
90
|
end
|
|
90
91
|
|
|
92
|
+
# The approval queue backing this session, or nil when the session was
|
|
93
|
+
# created without approval support. Use it to inspect pending actions
|
|
94
|
+
# and approve/reject them.
|
|
95
|
+
#
|
|
96
|
+
# @return [Ask::Agent::ApprovalQueue, nil]
|
|
97
|
+
attr_reader :approval_queue
|
|
98
|
+
|
|
91
99
|
def run(message, tools: nil, reset: true)
|
|
92
100
|
raise "Session deleted" if @deleted
|
|
93
101
|
raise "Session already running" if @running
|
|
@@ -336,6 +344,11 @@ module Ask
|
|
|
336
344
|
nil
|
|
337
345
|
end
|
|
338
346
|
|
|
347
|
+
# @return [Boolean] true while at least one async tool is running
|
|
348
|
+
def pending_tools?
|
|
349
|
+
@pending_mutex.synchronize { !@pending_tools.empty? }
|
|
350
|
+
end
|
|
351
|
+
|
|
339
352
|
# Completes a pending (async) tool call from a background thread.
|
|
340
353
|
#
|
|
341
354
|
# Adds the tool result to the conversation and, when the session is
|
|
@@ -411,6 +424,48 @@ module Ask
|
|
|
411
424
|
Ask::Agent::Extensions::AuditLog.new(self, adapter: config)
|
|
412
425
|
end
|
|
413
426
|
|
|
427
|
+
# Build the approval queue + policy when approval is enabled.
|
|
428
|
+
#
|
|
429
|
+
# `approval` accepts:
|
|
430
|
+
# - true → queue with defaults
|
|
431
|
+
# - a Hash → { require_approval:, auto_approve: } for the policy
|
|
432
|
+
# - an ApprovalQueue → uses it, with policy options from
|
|
433
|
+
# approval[:policy] if given
|
|
434
|
+
#
|
|
435
|
+
# When enabled, an ApprovalPolicy hook is prepended to the session's
|
|
436
|
+
# before_tool hooks so approval-required tools queue instead of running.
|
|
437
|
+
def build_approval(approval)
|
|
438
|
+
return nil unless approval
|
|
439
|
+
|
|
440
|
+
policy_opts = approval.is_a?(Hash) ? approval : {}
|
|
441
|
+
|
|
442
|
+
queue = if approval.is_a?(Ask::Agent::ApprovalQueue)
|
|
443
|
+
approval
|
|
444
|
+
elsif policy_opts[:queue].is_a?(Ask::Agent::ApprovalQueue)
|
|
445
|
+
policy_opts[:queue]
|
|
446
|
+
else
|
|
447
|
+
Ask::Agent::ApprovalQueue.new(
|
|
448
|
+
auto_approve: policy_opts[:auto_approve],
|
|
449
|
+
on_approve: ->(action) { apply_approved_action(action) },
|
|
450
|
+
on_reject: ->(action) { reject_pending_action(action) }
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
policy = Ask::Agent::Extensions::ApprovalPolicy.new(
|
|
455
|
+
queue: queue,
|
|
456
|
+
require_approval: policy_opts[:require_approval],
|
|
457
|
+
tools: @tools
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Prepend the approval gate so it runs before user hooks
|
|
461
|
+
@hooks = Hooks.new(
|
|
462
|
+
before_tool: [policy.method(:before_tool_call)] + Array(@hooks.instance_variable_get(:@before_tool)),
|
|
463
|
+
after_tool: @hooks.instance_variable_get(:@after_tool)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
queue
|
|
467
|
+
end
|
|
468
|
+
|
|
414
469
|
def build_chat(model, system_prompt, tools, **chat_options)
|
|
415
470
|
if model.respond_to?(:ask)
|
|
416
471
|
model
|
|
@@ -421,6 +476,48 @@ module Ask
|
|
|
421
476
|
end
|
|
422
477
|
end
|
|
423
478
|
|
|
479
|
+
# Execute an approved action's tool call and complete the pending tool,
|
|
480
|
+
# so the follow-up turn voices the outcome.
|
|
481
|
+
def apply_approved_action(action)
|
|
482
|
+
tool = @tools.find { |t| t.name == action.tool_name }
|
|
483
|
+
if tool
|
|
484
|
+
result = tool.call(action.args)
|
|
485
|
+
status = result.respond_to?(:ok?) ? (result.ok? ? "success" : "error") : "success"
|
|
486
|
+
complete_pending_tool(
|
|
487
|
+
tool_call_id: action.tool_call_id,
|
|
488
|
+
result: {
|
|
489
|
+
tool_name: action.tool_name,
|
|
490
|
+
message: result.to_s,
|
|
491
|
+
status: status,
|
|
492
|
+
is_error: status == "error"
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
else
|
|
496
|
+
complete_pending_tool(
|
|
497
|
+
tool_call_id: action.tool_call_id,
|
|
498
|
+
result: {
|
|
499
|
+
tool_name: action.tool_name,
|
|
500
|
+
message: "Tool not found: #{action.tool_name}",
|
|
501
|
+
status: "error",
|
|
502
|
+
is_error: true
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Notify the conversation that an action was rejected by the user.
|
|
509
|
+
def reject_pending_action(action)
|
|
510
|
+
complete_pending_tool(
|
|
511
|
+
tool_call_id: action.tool_call_id,
|
|
512
|
+
result: {
|
|
513
|
+
tool_name: action.tool_name,
|
|
514
|
+
message: "Action '#{action.tool_name}' was rejected by the user.",
|
|
515
|
+
status: "rejected",
|
|
516
|
+
is_error: false
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
end
|
|
520
|
+
|
|
424
521
|
def resolve_tools(tools)
|
|
425
522
|
resolved = tools.map do |tool|
|
|
426
523
|
tool.is_a?(Class) ? tool.new : tool
|
|
@@ -115,6 +115,17 @@ module Ask
|
|
|
115
115
|
return { tool_name: tool_call.name, message: hook_result[:reason], status: "blocked", is_error: true }
|
|
116
116
|
when :short_circuit
|
|
117
117
|
return { tool_name: tool_call.name, **hook_result[:result], status: "short_circuited" }
|
|
118
|
+
when :pending
|
|
119
|
+
# Queued for human approval — the loop hands the turn back with an
|
|
120
|
+
# interim reply; the action runs later via ApprovalQueue#approve.
|
|
121
|
+
return {
|
|
122
|
+
tool_name: tool_call.name,
|
|
123
|
+
message: hook_result[:reason] || "Pending approval",
|
|
124
|
+
status: "pending",
|
|
125
|
+
is_error: false,
|
|
126
|
+
tool_call_id: tool_call.id,
|
|
127
|
+
action_id: hook_result[:action_id]
|
|
128
|
+
}
|
|
118
129
|
end
|
|
119
130
|
|
|
120
131
|
return aborted_result(tool_call) if abort_controller&.aborted?
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Ask
|
|
|
25
25
|
autoload :Permissions, "ask/agent/extensions/permissions"
|
|
26
26
|
autoload :RateLimiter, "ask/agent/extensions/rate_limiter"
|
|
27
27
|
autoload :AuditLog, "ask/agent/extensions/audit_log"
|
|
28
|
+
autoload :ApprovalPolicy, "ask/agent/extensions/approval_policy"
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
module Middleware
|
|
@@ -257,6 +258,7 @@ require_relative "agent/evaluator"
|
|
|
257
258
|
require_relative "agent/tool_executor"
|
|
258
259
|
require_relative "agent/compactor"
|
|
259
260
|
require_relative "agent/hooks"
|
|
261
|
+
require_relative "agent/approval_queue"
|
|
260
262
|
require_relative "agent/configuration"
|
|
261
263
|
require_relative "agent/meta_agent"
|
|
262
264
|
require_relative "agent/persistence/base"
|
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.
|
|
4
|
+
version: 0.27.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -164,6 +164,7 @@ files:
|
|
|
164
164
|
- exe/askr
|
|
165
165
|
- lib/ask-agent.rb
|
|
166
166
|
- lib/ask/agent.rb
|
|
167
|
+
- lib/ask/agent/approval_queue.rb
|
|
167
168
|
- lib/ask/agent/chat.rb
|
|
168
169
|
- lib/ask/agent/cli.rb
|
|
169
170
|
- lib/ask/agent/compactor.rb
|
|
@@ -173,6 +174,7 @@ files:
|
|
|
173
174
|
- lib/ask/agent/definition.rb
|
|
174
175
|
- lib/ask/agent/evaluator.rb
|
|
175
176
|
- lib/ask/agent/events.rb
|
|
177
|
+
- lib/ask/agent/extensions/approval_policy.rb
|
|
176
178
|
- lib/ask/agent/extensions/audit_log.rb
|
|
177
179
|
- lib/ask/agent/extensions/audit_log/active_record_writer.rb
|
|
178
180
|
- lib/ask/agent/extensions/permissions.rb
|