ask-agent 0.40.29 → 0.41.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 +66 -0
- data/README.md +29 -0
- data/lib/ask/agent/exit_plan_mode.rb +1 -1
- data/lib/ask/agent/session.rb +247 -28
- data/lib/ask/agent/session_adapter.rb +119 -1
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +4 -7
- metadata +15 -5
- data/lib/ask/agent/approval_queue.rb +0 -249
- data/lib/ask/agent/policies/approval_policy.rb +0 -114
- data/lib/ask/agent/policies/permission_rules.rb +0 -130
- data/lib/ask/agent/policies/permissions.rb +0 -86
|
@@ -94,6 +94,12 @@ module Ask
|
|
|
94
94
|
)
|
|
95
95
|
end
|
|
96
96
|
agent.instance_variable_set(:@messages, agent.chat.messages.dup)
|
|
97
|
+
restore_persisted_approvals(
|
|
98
|
+
agent,
|
|
99
|
+
payload[:approvals] || payload["approvals"],
|
|
100
|
+
payload[:plan_approvals] || payload["plan_approvals"],
|
|
101
|
+
payload[:session_grants] || payload["session_grants"]
|
|
102
|
+
)
|
|
97
103
|
|
|
98
104
|
new(agent: agent, host: host, session_id: session_id, create: false)
|
|
99
105
|
end
|
|
@@ -238,10 +244,122 @@ module Ask
|
|
|
238
244
|
def build_snapshot
|
|
239
245
|
{
|
|
240
246
|
messages: (@agent.messages || []).map(&:to_h),
|
|
241
|
-
turn_count: @agent.turn_count || 0
|
|
247
|
+
turn_count: @agent.turn_count || 0,
|
|
248
|
+
# Durable permission state: JSON-safe v1 queue snapshots when the
|
|
249
|
+
# agent exposes a queue supporting the shared Permissions API.
|
|
250
|
+
# Nil when approval is off or unsupported (compatibility fallback
|
|
251
|
+
# — nothing durable to persist, so nothing to strand). Snapshot
|
|
252
|
+
# failures raise SessionAdapter::Error with queue context.
|
|
253
|
+
approvals: self.class.queue_snapshot_for(@agent, :approval_queue),
|
|
254
|
+
plan_approvals: self.class.queue_snapshot_for(@agent, :plan_queue),
|
|
255
|
+
session_grants: self.class.grants_snapshot_for(@agent)
|
|
242
256
|
}
|
|
243
257
|
end
|
|
244
258
|
|
|
259
|
+
def self.queue_snapshot_for(agent, queue_name)
|
|
260
|
+
return nil unless agent.respond_to?(queue_name)
|
|
261
|
+
|
|
262
|
+
queue = agent.public_send(queue_name)
|
|
263
|
+
return nil unless queue
|
|
264
|
+
return nil unless queue.respond_to?(:snapshot)
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
queue.snapshot
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
raise Error, "Failed to snapshot #{queue_name} queue (#{queue.class}): #{e.class}: #{e.message}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def self.grants_snapshot_for(agent)
|
|
274
|
+
return nil unless agent.respond_to?(:session_grants)
|
|
275
|
+
grants = agent.public_send(:session_grants)
|
|
276
|
+
grants&.respond_to?(:snapshot) ? grants.snapshot : nil
|
|
277
|
+
rescue StandardError => e
|
|
278
|
+
raise Error, "Failed to snapshot session grants (#{grants.class}): #{e.class}: #{e.message}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Restore persisted queue snapshots into the agent without firing
|
|
282
|
+
# callbacks or emitting approval-required events. For real Sessions
|
|
283
|
+
# this delegates to the session's silent restore (which also rebuilds
|
|
284
|
+
# pending-tool registrations so approve/reject completes exactly
|
|
285
|
+
# once); for generic agents it restores directly when the surface
|
|
286
|
+
# allows.
|
|
287
|
+
#
|
|
288
|
+
# Compatibility fallback (cannot strand): nil snapshots and empty
|
|
289
|
+
# pending_actions lists are safe no-ops. Any other unrestorable state
|
|
290
|
+
# — non-Hash snapshot, missing/non-Array pendings, missing queue or
|
|
291
|
+
# queue without restore support while pendings exist, a non-empty
|
|
292
|
+
# target queue, or a restore_pending failure — raises
|
|
293
|
+
# SessionAdapter::Error (or Ask::Agent::Error from the session
|
|
294
|
+
# delegate) with queue context instead of silently dropping actions.
|
|
295
|
+
def self.restore_persisted_approvals(agent, approvals_snapshot, plan_snapshot, grants_snapshot = nil)
|
|
296
|
+
if agent.respond_to?(:restore_persisted_approvals, true)
|
|
297
|
+
agent.send(:restore_persisted_approvals, approvals_snapshot, plan_snapshot, grants_snapshot)
|
|
298
|
+
return
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
{ approval_queue: approvals_snapshot, plan_queue: plan_snapshot }.each do |queue_name, snapshot|
|
|
302
|
+
restore_into_generic_queue(agent, queue_name, snapshot)
|
|
303
|
+
end
|
|
304
|
+
return if grants_snapshot.nil?
|
|
305
|
+
|
|
306
|
+
unless grants_snapshot.is_a?(Hash)
|
|
307
|
+
raise Error, "Cannot restore session grants: snapshot must be a Hash, got #{grants_snapshot.class}"
|
|
308
|
+
end
|
|
309
|
+
tools = grants_snapshot[:granted_tools] || grants_snapshot["granted_tools"]
|
|
310
|
+
unless tools.is_a?(Array) && tools.all? { |tool| tool.is_a?(String) && !tool.empty? }
|
|
311
|
+
raise Error, "Cannot restore session grants: granted_tools must be an Array of non-empty Strings"
|
|
312
|
+
end
|
|
313
|
+
return if tools.empty?
|
|
314
|
+
unless agent.respond_to?(:session_grants)
|
|
315
|
+
raise Error, "Cannot restore session grants: agent has no session grants surface"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
grants = agent.public_send(:session_grants)
|
|
319
|
+
raise Error, "Cannot restore session grants: agent has no grants store" unless grants
|
|
320
|
+
begin
|
|
321
|
+
grants.restore_snapshot(grants_snapshot)
|
|
322
|
+
rescue StandardError => e
|
|
323
|
+
raise Error, "Cannot restore session grants: #{e.class}: #{e.message}"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def self.restore_into_generic_queue(agent, queue_name, snapshot)
|
|
328
|
+
return nil if snapshot.nil?
|
|
329
|
+
|
|
330
|
+
unless snapshot.is_a?(Hash)
|
|
331
|
+
raise Error, "Cannot restore #{queue_name} queue: snapshot must be a Hash, got #{snapshot.class}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
pendings = snapshot[:pending_actions] || snapshot["pending_actions"]
|
|
335
|
+
if pendings.nil?
|
|
336
|
+
raise Error, "Cannot restore #{queue_name} queue: snapshot missing pending_actions"
|
|
337
|
+
end
|
|
338
|
+
unless pendings.is_a?(Array)
|
|
339
|
+
raise Error, "Cannot restore #{queue_name} queue: pending_actions must be an Array, got #{pendings.class}"
|
|
340
|
+
end
|
|
341
|
+
return nil if pendings.empty?
|
|
342
|
+
return nil unless agent.respond_to?(queue_name)
|
|
343
|
+
|
|
344
|
+
queue = agent.public_send(queue_name)
|
|
345
|
+
unless queue
|
|
346
|
+
raise Error, "Cannot restore #{queue_name} queue: agent has no #{queue_name} queue but snapshot carries #{pendings.size} pending action(s)"
|
|
347
|
+
end
|
|
348
|
+
unless queue.respond_to?(:restore_pending) && queue.respond_to?(:pending_actions)
|
|
349
|
+
raise Error, "Cannot restore #{queue_name} queue: #{queue.class} does not support restore_pending"
|
|
350
|
+
end
|
|
351
|
+
if queue.respond_to?(:any_pending?) && queue.any_pending?
|
|
352
|
+
raise Error, "Cannot restore #{queue_name} queue: target queue already holds pending actions"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
begin
|
|
356
|
+
queue.restore_pending(snapshot)
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
raise Error, "Cannot restore #{queue_name} queue: #{e.class}: #{e.message}"
|
|
359
|
+
end
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
245
363
|
def self.deserialize_content(content)
|
|
246
364
|
content.is_a?(Array) ? content.map { |block| Ask::Content.from_h(block) } : content
|
|
247
365
|
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "ask-llm-providers"
|
|
|
9
9
|
require "ask-tools"
|
|
10
10
|
require "ask-state-providers"
|
|
11
11
|
require "ask-runtime"
|
|
12
|
+
require "ask/permissions"
|
|
12
13
|
|
|
13
14
|
module Ask
|
|
14
15
|
module Agent
|
|
@@ -29,15 +30,12 @@ module Ask
|
|
|
29
30
|
# swap them freely (see Ask::Agent::Hooks for the seam).
|
|
30
31
|
#
|
|
31
32
|
# A policy is NOT core machinery. Core mechanisms live on Session — the
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# classification policy wired on top of them.
|
|
33
|
+
# :pending result status and the `approval: true` option are core;
|
|
34
|
+
# Ask::Permissions::ApprovalPolicy (from the ask-permissions gem) is the
|
|
35
|
+
# reference classification policy wired on top of them.
|
|
35
36
|
module Policies
|
|
36
|
-
autoload :Permissions, "ask/agent/policies/permissions"
|
|
37
37
|
autoload :RateLimiter, "ask/agent/policies/rate_limiter"
|
|
38
38
|
autoload :AuditLog, "ask/agent/policies/audit_log"
|
|
39
|
-
autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
|
|
40
|
-
autoload :PermissionRules, "ask/agent/policies/permission_rules"
|
|
41
39
|
end
|
|
42
40
|
|
|
43
41
|
autoload :ToolCallRepair, "ask/agent/tool_call_repair"
|
|
@@ -223,7 +221,6 @@ require_relative "agent/evaluator"
|
|
|
223
221
|
require_relative "agent/tool_executor"
|
|
224
222
|
require_relative "agent/compactor"
|
|
225
223
|
require_relative "agent/hooks"
|
|
226
|
-
require_relative "agent/approval_queue"
|
|
227
224
|
require_relative "agent/configuration"
|
|
228
225
|
require_relative "agent/meta_agent"
|
|
229
226
|
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.41.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -135,6 +135,20 @@ dependencies:
|
|
|
135
135
|
- - ">="
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: 0.1.2
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: ask-permissions
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: 0.2.0
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: 0.2.0
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: minitest
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -192,7 +206,6 @@ files:
|
|
|
192
206
|
- exe/askr
|
|
193
207
|
- lib/ask-agent.rb
|
|
194
208
|
- lib/ask/agent.rb
|
|
195
|
-
- lib/ask/agent/approval_queue.rb
|
|
196
209
|
- lib/ask/agent/artifact_store.rb
|
|
197
210
|
- lib/ask/agent/chat.rb
|
|
198
211
|
- lib/ask/agent/checkpoint_store.rb
|
|
@@ -221,11 +234,8 @@ files:
|
|
|
221
234
|
- lib/ask/agent/output_read.rb
|
|
222
235
|
- lib/ask/agent/persistence/base.rb
|
|
223
236
|
- lib/ask/agent/persistence/in_memory.rb
|
|
224
|
-
- lib/ask/agent/policies/approval_policy.rb
|
|
225
237
|
- lib/ask/agent/policies/audit_log.rb
|
|
226
238
|
- lib/ask/agent/policies/audit_log/active_record_writer.rb
|
|
227
|
-
- lib/ask/agent/policies/permission_rules.rb
|
|
228
|
-
- lib/ask/agent/policies/permissions.rb
|
|
229
239
|
- lib/ask/agent/policies/rate_limiter.rb
|
|
230
240
|
- lib/ask/agent/reflector.rb
|
|
231
241
|
- lib/ask/agent/scheduler.rb
|
|
@@ -1,249 +0,0 @@
|
|
|
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
|
-
# @!attribute [rw] on_approve
|
|
59
|
-
# Called with an {Action} when it is approved and applied. The
|
|
60
|
-
# session wires this to execute the real tool call; can be replaced
|
|
61
|
-
# after construction (e.g. by queue subclasses that also emit events).
|
|
62
|
-
# @!attribute [rw] on_reject
|
|
63
|
-
# Called with an {Action} when it is rejected.
|
|
64
|
-
# @!attribute [rw] on_submit
|
|
65
|
-
# Called with the new {Action} when it is submitted — BEFORE the
|
|
66
|
-
# auto-approval drain runs, so subscribers can register the pending
|
|
67
|
-
# call before it is applied. The session wires this to register the
|
|
68
|
-
# pending tool call, closing the race where an approval lands while
|
|
69
|
-
# the executor is still in flight.
|
|
70
|
-
attr_accessor :on_approve, :on_reject, :on_submit
|
|
71
|
-
|
|
72
|
-
def initialize(on_approve: nil, on_reject: nil, auto_approve: nil, on_submit: nil)
|
|
73
|
-
@on_approve = on_approve
|
|
74
|
-
@on_reject = on_reject
|
|
75
|
-
@on_submit = on_submit
|
|
76
|
-
@auto_approve = auto_approve || {}
|
|
77
|
-
@actions = {}
|
|
78
|
-
@next_id = 1
|
|
79
|
-
@mutex = Mutex.new
|
|
80
|
-
@draining = false
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Queue a tool action for approval.
|
|
84
|
-
#
|
|
85
|
-
# @param tool_call_id [String] original tool call id from the LLM
|
|
86
|
-
# @param tool_name [String] tool name
|
|
87
|
-
# @param args [Hash] arguments for the tool
|
|
88
|
-
# @param auto_approvable [Boolean] whether the tool permits auto-approval
|
|
89
|
-
# @param message [String, nil] human-readable description
|
|
90
|
-
# @return [Integer] the action id
|
|
91
|
-
def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
|
|
92
|
-
action = @mutex.synchronize do
|
|
93
|
-
action = Action.new(
|
|
94
|
-
id: @next_id,
|
|
95
|
-
tool_call_id: tool_call_id,
|
|
96
|
-
tool_name: tool_name,
|
|
97
|
-
args: args || {},
|
|
98
|
-
auto_approvable: auto_approvable,
|
|
99
|
-
status: :pending,
|
|
100
|
-
submitted_at: Time.now,
|
|
101
|
-
message: message
|
|
102
|
-
)
|
|
103
|
-
@next_id += 1
|
|
104
|
-
@actions[action.id] = action
|
|
105
|
-
action
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Notify BEFORE the drain: an auto-approvable action is applied
|
|
109
|
-
# (and possibly completed) inside drain, and listeners need to
|
|
110
|
-
# observe the submission first.
|
|
111
|
-
@on_submit&.call(action)
|
|
112
|
-
|
|
113
|
-
drain
|
|
114
|
-
action.id
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# All actions still awaiting a decision, in id order.
|
|
118
|
-
#
|
|
119
|
-
# @return [Array<Action>]
|
|
120
|
-
def pending_actions
|
|
121
|
-
@mutex.synchronize do
|
|
122
|
-
@actions.values.select { |a| a.status == :pending }.sort_by(&:id)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# @param id [Integer]
|
|
127
|
-
# @return [Boolean] whether the action is still awaiting a decision
|
|
128
|
-
def pending?(id)
|
|
129
|
-
@mutex.synchronize do
|
|
130
|
-
a = @actions[id]
|
|
131
|
-
a && a.status == :pending
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# @return [Boolean] true while at least one action awaits a decision
|
|
136
|
-
def any_pending?
|
|
137
|
-
pending_actions.any?
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Look up an action by id.
|
|
141
|
-
#
|
|
142
|
-
# @param id [Integer]
|
|
143
|
-
# @return [Action, nil]
|
|
144
|
-
def [](id)
|
|
145
|
-
@mutex.synchronize { @actions[id] }
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Approve specific actions (by id), applying them in id order.
|
|
149
|
-
#
|
|
150
|
-
# @param ids [Array<Integer>]
|
|
151
|
-
# @return [Array<Action>] the actions that were approved
|
|
152
|
-
def approve(*ids)
|
|
153
|
-
actions = ids.flatten.filter_map { |id| @mutex.synchronize { @actions[id] } }
|
|
154
|
-
actions.select! { |a| a.status == :pending }
|
|
155
|
-
actions.sort_by!(&:id)
|
|
156
|
-
actions.each { |a| apply(a) }
|
|
157
|
-
actions
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Reject specific actions (by id).
|
|
161
|
-
#
|
|
162
|
-
# @param ids [Array<Integer>]
|
|
163
|
-
# @return [Array<Action>] the actions that were rejected
|
|
164
|
-
def reject(*ids)
|
|
165
|
-
actions = ids.flatten.filter_map { |id| @mutex.synchronize { @actions[id] } }
|
|
166
|
-
actions.select! { |a| a.status == :pending }
|
|
167
|
-
actions.sort_by!(&:id)
|
|
168
|
-
actions.each { |a| reject_action(a) }
|
|
169
|
-
actions
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Approve all currently pending actions, in id order.
|
|
173
|
-
#
|
|
174
|
-
# @return [Array<Action>]
|
|
175
|
-
def approve_all
|
|
176
|
-
approve(*pending_actions.map(&:id))
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Reject all currently pending actions, in id order.
|
|
180
|
-
#
|
|
181
|
-
# @return [Array<Action>]
|
|
182
|
-
def reject_all
|
|
183
|
-
reject(*pending_actions.map(&:id))
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
private
|
|
187
|
-
|
|
188
|
-
# Apply eligible pending actions in id order, stopping at the first
|
|
189
|
-
# action that is NOT auto-eligible (a manual gate) — nothing is
|
|
190
|
-
# silently applied past a human review point. Single-flight guard so
|
|
191
|
-
# concurrent drains cannot double-apply.
|
|
192
|
-
def drain
|
|
193
|
-
return if @mutex.synchronize { @draining }
|
|
194
|
-
@mutex.synchronize { @draining = true }
|
|
195
|
-
|
|
196
|
-
begin
|
|
197
|
-
loop do
|
|
198
|
-
action = @mutex.synchronize do
|
|
199
|
-
@actions.values.select { |a| a.status == :pending }
|
|
200
|
-
.sort_by(&:id)
|
|
201
|
-
.first
|
|
202
|
-
end
|
|
203
|
-
break unless action
|
|
204
|
-
|
|
205
|
-
if auto_approvable?(action)
|
|
206
|
-
apply(action)
|
|
207
|
-
else
|
|
208
|
-
break # manual gate — stop, never skip ahead
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
ensure
|
|
212
|
-
@mutex.synchronize { @draining = false }
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def auto_approvable?(action)
|
|
217
|
-
action.auto_approvable && @auto_approve[action.tool_name] == true
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def apply(action)
|
|
221
|
-
claimed = @mutex.synchronize do
|
|
222
|
-
return false unless action.status == :pending
|
|
223
|
-
@actions[action.id] = action.with(status: :applying)
|
|
224
|
-
end
|
|
225
|
-
@on_approve&.call(claimed)
|
|
226
|
-
@mutex.synchronize do
|
|
227
|
-
@actions[action.id] = claimed.with(status: :approved)
|
|
228
|
-
end
|
|
229
|
-
true
|
|
230
|
-
rescue StandardError
|
|
231
|
-
# Failed apply: leave the action pending so the user can retry or
|
|
232
|
-
# reject it. Re-raise so the caller (approve/drain) surfaces it.
|
|
233
|
-
@mutex.synchronize do
|
|
234
|
-
@actions[action.id] = action.with(status: :pending) if @actions[action.id]&.status == :applying
|
|
235
|
-
end
|
|
236
|
-
raise
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def reject_action(action)
|
|
240
|
-
return false unless @mutex.synchronize { action.status == :pending }
|
|
241
|
-
@on_reject&.call(action)
|
|
242
|
-
@mutex.synchronize do
|
|
243
|
-
@actions[action.id] = action.with(status: :rejected)
|
|
244
|
-
end
|
|
245
|
-
true
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
end
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ask
|
|
4
|
-
module Agent
|
|
5
|
-
module Policies
|
|
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::Policies::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 rules [Ask::Agent::Policies::PermissionRules, nil] persisted
|
|
36
|
-
# allow/ask/deny patterns. Rules classify first and take precedence
|
|
37
|
-
# over tool declarations: :deny blocks, :allow proceeds without the
|
|
38
|
-
# queue, :ask queues regardless of auto-approvable.
|
|
39
|
-
# @param tools [Array<Object>, nil] the session's resolved tool
|
|
40
|
-
# instances, used to read class-level declarations
|
|
41
|
-
# (`approval_required`, `auto_approvable`). When nil, only the
|
|
42
|
-
# rule-based lists classify calls.
|
|
43
|
-
def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
|
|
44
|
-
@queue = queue
|
|
45
|
-
@require_approval = require_approval
|
|
46
|
-
@rules = rules
|
|
47
|
-
@tools = Array(tools)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Hook entry point — matches the +before_tool+ hook signature.
|
|
51
|
-
#
|
|
52
|
-
# @param tool_call [Ask::Agent::ToolCallInfo]
|
|
53
|
-
# @param _context [Hash]
|
|
54
|
-
# @return [Hash] {action: :proceed} to run, {action: :pending,
|
|
55
|
-
# action_id:, reason:} to queue for approval, or {action: :block,
|
|
56
|
-
# reason:} to refuse
|
|
57
|
-
def before_tool_call(tool_call, _context)
|
|
58
|
-
case @rules&.classify(tool_call.name, tool_call.arguments)
|
|
59
|
-
when :deny
|
|
60
|
-
return { action: :block, reason: "Denied by permission rules: '#{tool_call.name}'" }
|
|
61
|
-
when :allow
|
|
62
|
-
return { action: :proceed }
|
|
63
|
-
when :ask
|
|
64
|
-
return queue_for_approval(tool_call, auto_approvable: false)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
return { action: :proceed } unless approval_required?(tool_call.name)
|
|
68
|
-
|
|
69
|
-
queue_for_approval(tool_call, auto_approvable: tool_auto_approvable?(tool_call.name))
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
def queue_for_approval(tool_call, auto_approvable:)
|
|
75
|
-
action_id = @queue.submit(
|
|
76
|
-
tool_call_id: tool_call.id,
|
|
77
|
-
tool_name: tool_call.name,
|
|
78
|
-
args: tool_call.arguments,
|
|
79
|
-
auto_approvable: auto_approvable,
|
|
80
|
-
message: "Calling \"#{tool_call.name}\" requires approval"
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
{ action: :pending, action_id: action_id, reason: "Tool '#{tool_call.name}' requires approval" }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def approval_required?(tool_name)
|
|
87
|
-
return true if @require_approval == :all
|
|
88
|
-
|
|
89
|
-
rule_matches?(@require_approval, tool_name) || tool_declares_approval?(tool_name)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def rule_matches?(rules, tool_name)
|
|
93
|
-
Array(rules).any? do |rule|
|
|
94
|
-
rule == tool_name || (rule.is_a?(Regexp) && rule.match?(tool_name))
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def tool_declares_approval?(tool_name)
|
|
99
|
-
tool = tool_for(tool_name)
|
|
100
|
-
tool&.respond_to?(:approval_required?) && tool.approval_required?
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def tool_auto_approvable?(tool_name)
|
|
104
|
-
tool = tool_for(tool_name)
|
|
105
|
-
tool&.respond_to?(:auto_approvable?) && tool.auto_approvable?
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def tool_for(tool_name)
|
|
109
|
-
@tools.find { |t| t.respond_to?(:name) && t.name == tool_name }
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|