ask-agent 0.26.1 → 0.27.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: e64d0487f0c2798ee7515fa7b926747a4994be257a9d5290ee87461cd762018e
4
- data.tar.gz: 8f5102405021977edee53cbc4bd9e743b4dca0db61362a5e14de346b99590a5b
3
+ metadata.gz: f1728d64a66438d1ff5ed51490420b7f9f87d806fc8a5bd98317d9c4de44e68e
4
+ data.tar.gz: 2172947edba0c5a906ebdc01643eaa0adda23d1405d22dcb0dd5a662c71e4944
5
5
  SHA512:
6
- metadata.gz: 718a912eb746699b9c2f834ff75303d27c5391b1d7c552137d023e5526595f8ea973af00b89927c6984a94b5608ad37dc55c8dd28e15e5dcc083259e9880f6eb
7
- data.tar.gz: e7e2afb7ab3c2552473191dc7a0c3aacb409c467f3ce721beb0a8bfe2fb5ee47e6c66a3ba72db7e12ae56b632b847ee21e87a884754a2e3210a125353f93a814
6
+ metadata.gz: cb6be0c48b35c3b7e68ba73edc98fbe9209ffcdc58ee2a7524dd12ea0d3202b0d62397f449579d4b8154b97876cfc57da1031eb004a02fb25c61765b11e764bd
7
+ data.tar.gz: e2d4f5c8fa6808c0d1e73e9d237c56d8e48fb4bc8630b9bf0feb89309bab872a1bca8a42d603030dafadeeb30d203e35e413a533dded5d3a9086e8b8031ef3fc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,64 @@
1
+ ## [0.27.1] — 2026-08-06
2
+
3
+ ### Fixed
4
+
5
+ - **`chat.ask` / `chat.stream.ask` events now measure real LLM latency.** The
6
+ event was emitted after the call without a block, so `event.duration` was
7
+ ~0ms and duration metrics (e.g. `ask_llm_duration_seconds`,
8
+ `llm.duration_ms` spans) were meaningless. The provider call now runs
9
+ inside the instrument block; tokens/cost/tool_calls are enriched through a
10
+ shared nested `usage` payload hash (known only after the call returns) and
11
+ subscribers read it from there. Instrumentation failures can no longer
12
+ fail an `ask` — a wrapper error before the call falls through and runs the
13
+ call without telemetry, and a subscriber error after success returns the
14
+ response.
15
+
16
+ ## [0.27.0] — 2026-08-06
17
+
18
+ ### Added
19
+
20
+ - **Human-in-the-loop tool approval — `Ask::Agent::ApprovalQueue`.** Tools
21
+ declared `approval_required` (ask-tools) are queued instead of executed:
22
+ the agent receives a pending result and continues, and the tool runs only
23
+ after a human approves it. Built on the async-tools seam
24
+ (`Ask::Result.pending` → `register_pending_tool` → `complete_pending_tool`).
25
+
26
+ ```ruby
27
+ session = Ask::Agent::Session.new(
28
+ model: "gpt-4o",
29
+ tools: [SendEmail], # SendEmail.approval_required true
30
+ approval: { auto_approve: {} }
31
+ )
32
+ session.run("Email bob about the launch")
33
+
34
+ # Later, when the user decides:
35
+ session.approval_queue.pending_actions # inspect what's waiting
36
+ session.approval_queue.approve_all # or approve(id) / reject(id)
37
+ ```
38
+
39
+ - `approval: true` enables with defaults; a Hash accepts
40
+ `require_approval:` (tool names / regexps / `:all`) and `auto_approve:`
41
+ (user-enabled rules keyed by tool name). A custom `ApprovalQueue`
42
+ instance is accepted too.
43
+ - **Auto-approval is a dual signal** — a tool marked `auto_approvable`
44
+ AND a user rule enabling it. Nothing is silently applied past a manual
45
+ (non-auto-approvable) gate; eligible actions drain in id order with a
46
+ single-flight guard (no double-apply).
47
+ - **Approving executes the real tool** and feeds the result into the
48
+ conversation; **rejecting** injects a "rejected by the user" message and
49
+ the agent adapts. Failed applies leave the action pending for retry.
50
+ - `Session#approval_queue` returns the queue (nil when approval is off).
51
+
52
+ - **`Ask::Agent::Extensions::ApprovalPolicy`** — the classification hook.
53
+ Queues calls for tools whose class declares `approval_required`, or whose
54
+ name matches rule-based lists, or (with `require_approval: :all`) every
55
+ call. Usable standalone as a `before_tool` hook.
56
+
57
+ ### Changed
58
+
59
+ - `ToolExecutor` before-tool hooks now support a `:pending` action alongside
60
+ `:block` and `:short_circuit`, returning a pending tool result.
61
+
1
62
  ## [0.26.1] - 2026-08-05
2
63
 
3
64
  ### 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
@@ -73,8 +73,6 @@ module Ask
73
73
  }.compact
74
74
  )
75
75
 
76
- emit_instrumentation(stream, response_msg)
77
-
78
76
  response_msg
79
77
  end
80
78
 
@@ -208,20 +206,35 @@ module Ask
208
206
  begin
209
207
  req = build_request(stream)
210
208
 
211
- result = if @middleware_pipeline
212
- @middleware_pipeline.invoke(provider, req) do
209
+ # The chat.ask event wraps the actual provider call so
210
+ # event.duration measures the true LLM latency. Tokens/cost are
211
+ # only known once the call returns, so the (mutable) payload is
212
+ # passed into the block and enriched there — before the event's
213
+ # finish fires.
214
+ response = instrument_llm_call(stream) do |payload|
215
+ result = if @middleware_pipeline
216
+ @middleware_pipeline.invoke(provider, req) do
217
+ call_provider(req, calls_acc, &block)
218
+ end
219
+ else
213
220
  call_provider(req, calls_acc, &block)
214
221
  end
215
- else
216
- call_provider(req, calls_acc, &block)
217
- end
218
222
 
219
- # Flush any buffered stream transforms (e.g. TextBuffer)
220
- if block && @transform_pipeline
221
- flush_transforms(&block)
223
+ # Flush any buffered stream transforms (e.g. TextBuffer)
224
+ if block && @transform_pipeline
225
+ flush_transforms(&block)
226
+ end
227
+
228
+ response = build_response_from_result(result, calls_acc, stream)
229
+ usage = payload[:usage] ||= {}
230
+ usage[:input_tokens] = response.input_tokens
231
+ usage[:output_tokens] = response.output_tokens
232
+ usage[:cost] = response.cost
233
+ usage[:tool_calls] = response.tool_call?
234
+ response
222
235
  end
223
236
 
224
- return build_response_from_result(result, calls_acc, stream)
237
+ return response
225
238
  rescue Ask::RateLimitError => e
226
239
  raise if attempt >= MAX_CHAT_RETRIES - 1
227
240
 
@@ -357,28 +370,47 @@ module Ask
357
370
  nil
358
371
  end
359
372
 
360
- def emit_instrumentation(stream, response_msg)
361
- return unless defined?(Ask::Instrumentation)
362
-
373
+ # Run the provider call inside the chat.ask event so event.duration
374
+ # measures the real LLM latency. The event payload is yielded to the
375
+ # block so the caller can enrich it (tokens, cost) before the event
376
+ # finishes.
377
+ #
378
+ # Instrumentation must never break the chat loop: a subscriber error
379
+ # after the call succeeded is swallowed (the response is returned);
380
+ # an error before the block ran falls through and runs the call
381
+ # without telemetry. Only real LLM errors propagate.
382
+ def instrument_llm_call(stream)
363
383
  payload = {
364
384
  model: @model_id,
365
385
  provider: @model_info.provider,
366
- input_tokens: response_msg.input_tokens,
367
- output_tokens: response_msg.output_tokens,
368
- cost: response_msg.cost,
369
- tool_calls: response_msg.tool_call?,
370
386
  stream: stream,
371
387
  middleware: @middleware_pipeline&.configured?,
372
- stream_transforms: @transform_pipeline&.configured?
388
+ stream_transforms: @transform_pipeline&.configured?,
389
+ # Tokens/cost are only known once the call returns, and
390
+ # instrument() copies the payload shallowly — this nested hash is
391
+ # shared with the event, so in-block enrichment is visible to
392
+ # subscribers at finish time.
393
+ usage: {}
373
394
  }.compact
374
395
 
375
- if stream
376
- Ask::Instrumentation.instrument("chat.stream.ask", payload)
396
+ called = false
397
+ result = nil
398
+ if defined?(Ask::Instrumentation)
399
+ Ask::Instrumentation.instrument(stream ? "chat.stream.ask" : "chat.ask", payload) do
400
+ called = true
401
+ result = yield(payload)
402
+ end
377
403
  else
378
- Ask::Instrumentation.instrument("chat.ask", payload)
404
+ called = true
405
+ result = yield(payload)
379
406
  end
407
+ result
380
408
  rescue StandardError
381
- nil
409
+ return result if called && !result.nil?
410
+
411
+ raise if called
412
+
413
+ yield({})
382
414
  end
383
415
  end
384
416
  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
@@ -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
@@ -416,6 +424,48 @@ module Ask
416
424
  Ask::Agent::Extensions::AuditLog.new(self, adapter: config)
417
425
  end
418
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
+
419
469
  def build_chat(model, system_prompt, tools, **chat_options)
420
470
  if model.respond_to?(:ask)
421
471
  model
@@ -426,6 +476,48 @@ module Ask
426
476
  end
427
477
  end
428
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
+
429
521
  def resolve_tools(tools)
430
522
  resolved = tools.map do |tool|
431
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?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.26.1"
5
+ VERSION = "0.27.1"
6
6
  end
7
7
  end
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.26.1
4
+ version: 0.27.1
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