ask-agent 0.40.30 → 0.42.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6cbbcaccaadc0c18309984778974b4bcd63266acfd5329b855bfe8549ceed08b
4
- data.tar.gz: e57d10ac194c5fcd6811842955527da4aa1a029a8c1855cb386851230e1335b7
3
+ metadata.gz: fa59129858a82b94badc3ec8e378457e2de2d0ffca3d5814797bc5e5ac0bb25d
4
+ data.tar.gz: cb266f3ff5e66ddc0227fd609d8ba99141190dd433ffe7ef5ab221a620cbcda8
5
5
  SHA512:
6
- metadata.gz: c82139550f7fceacc363517c898f904a63900ba886db8f41532818721892a7a4e6e7e06b8cba78757831bed9846849238a3561032ba9e868f98e370310d8b8b3
7
- data.tar.gz: 0eb17641350c57e697802700645edb9cdd271c2313b90a625a97ddf756f1fc4ca3dde68643aa7c5336de3bcdbee445e712673fe8b9bac709bc8199ae819f47b7
6
+ metadata.gz: 2dfaaa16987bf0eee0c03d13b415b8b0b02a210291f264d4a550e1b4eb86ee84671af2c5ec3d6f5aed7cfa34eae82b17164ecc87a13e1f80ac90a54cc94ffad4
7
+ data.tar.gz: 6e0c7e726ec4bcc602b06efd47eb272d67ae5441a157b666561f74ae9b32834264ad950ef638a238b6afe3994657eb906c453069d34083c9f86ee7ea6a0cb04a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.42.0] - 2026-09-23
4
+ ## [0.41.0] - 2026-09-23
5
+
6
+ - Approval decisions can now be granted for the current project through a
7
+ host-provided `project_grants` collaborator; hosts own its persistence and
8
+ project identity. Ask App Server supplies a workspace-scoped implementation.
9
+
10
+ ### Added
11
+
12
+ - Applying approval with `scope: :session` grants that tool for the session;
13
+ once/project approvals remain one-shot from Ask Agent's perspective. Grants
14
+ persist and restore with both `Session.load` and `SessionAdapter.resume`.
15
+ - `approval: { mode: ... }` — the permission mode (`:full_access`,
16
+ `:ask_before_changes`, `:read_only`) is forwarded to
17
+ `Ask::Permissions::ApprovalPolicy` when `Session#build_approval` wires the
18
+ policy, so sessions can run under a baseline mode alongside
19
+ `require_approval:` / `rules:` / grants.
20
+
21
+ ### Fixed
22
+
23
+ - **Approval / plan queue persistence restores without re-emitting and approves exactly once.**
24
+ `Session.persist!` stores JSON-safe queue snapshots (`approvals:`,
25
+ `plan_approvals:`); `Session.load` re-enables the queues only when the
26
+ blob carries pending actions and silently rebuilds pending-tool
27
+ registrations so a later approve/reject completes the original call once.
28
+ `SessionAdapter` snapshots carry the same state and resume restores it
29
+ without firing callbacks. Malformed snapshots (non-Hash, missing /
30
+ non-Array pendings, missing queue while pendings exist, non-empty target,
31
+ or `restore_pending` / `snapshot` failures) now raise a contextual
32
+ `Ask::Agent::Error` / `SessionAdapter::Error` instead of being silently
33
+ swallowed; nil snapshots and empty pending lists remain safe no-ops, and
34
+ queues predating the Permissions API persist as nil (documented
35
+ compatibility fallback that cannot strand actions). Framework-injected
36
+ tools (`load_skill`, `todo_write`, `exit_plan_mode`, memory tools,
37
+ `output_read`) are excluded from persisted tool lists, avoiding
38
+ `Session.load` reconstruction warnings.
39
+
40
+ ### Changed
41
+
42
+ - **Plan-mode tool gate delegates to `Ask::Permissions::PlanModePolicy`.**
43
+ `Session` now configures one shared policy (`allowed_tools:` from the
44
+ session's read-only tool list, `exit_tool: "exit_plan_mode"`) instead of
45
+ hand-rolling the allow/block check in `plan_mode_gate`, which now only
46
+ checks that plan mode is still active and delegates. The gate hook is
47
+ still installed only when plan mode is enabled, and approval still turns
48
+ the gate off. Exposes the configured policy as `Session#plan_mode_policy`.
49
+ - Unknown keys in the `approval:` hash now raise `ArgumentError` instead of
50
+ being silently dropped — a misspelled option previously disabled a safety
51
+ control with no signal. Accepted keys are unchanged: `queue:`,
52
+ `require_approval:`, `rules:`, `auto_approve:`, `session_grants:`,
53
+ `project_grants:`, and the new `mode:`.
54
+ ## [0.40.30] — 2026-09-23
55
+
3
56
  ### Added
4
57
 
5
58
  - **`Session#run(runtime_event_sink:)` forwards ask-runtime tool lifecycle events** through the agent loop and recursive turns to `ToolExecutor#execute_batch`.
data/README.md CHANGED
@@ -120,6 +120,35 @@ under multiple OpenAI-compatible providers). A `provider:` passed to
120
120
  `Session.new` or declared in an agent `Definition` always wins over the global
121
121
  default.
122
122
 
123
+ ### Tool approval
124
+
125
+ `Session.new(approval: ...)` turns on human-in-the-loop approval: matching
126
+ tool calls queue on `session.approval_queue` instead of executing until
127
+ `approve(id)` / `reject(id)` is called. `approval: true` enables defaults; a
128
+ Hash accepts:
129
+
130
+ | Option | Purpose |
131
+ |---|---|
132
+ | `require_approval:` | tool names / regexps / `:all` that must be approved |
133
+ | `auto_approve:` | user-enabled rules keyed by tool name (pairs with `auto_approvable` tools) |
134
+ | `rules:` | an `Ask::Permissions::PermissionRules` block (`allow` / `ask` / `deny`) |
135
+ | `mode:` | baseline policy — `:full_access`, `:ask_before_changes`, or `:read_only` |
136
+ | `queue:` | a custom `Ask::Permissions::ApprovalQueue` |
137
+ | `session_grants:` / `project_grants:` | grant collaborators (session- and project-scoped approvals) |
138
+
139
+ ```ruby
140
+ session = Ask::Agent::Session.new(
141
+ model: "gpt-4o",
142
+ tools: [SendEmail],
143
+ approval: { mode: :read_only }
144
+ )
145
+ ```
146
+
147
+ `mode:` is forwarded to `Ask::Permissions::ApprovalPolicy`: `:full_access`
148
+ runs every tool without asking, `:ask_before_changes` queues tool calls for
149
+ approval, and `:read_only` refuses them outright. Unknown keys in the
150
+ `approval:` hash raise `ArgumentError` instead of being silently ignored.
151
+
123
152
  ## Durable sessions (ask-session)
124
153
 
125
154
  `Ask::Agent::SessionAdapter` bridges a session to an event-sourced
@@ -10,6 +10,12 @@ module Ask
10
10
  # loop registrations resurrecting ghost pending calls.
11
11
  RECENTLY_COMPLETED_MAX = 200
12
12
 
13
+ # Options accepted by the `approval:` hash. Unknown keys raise so a
14
+ # misspelled option can never silently disable a safety control.
15
+ APPROVAL_OPTIONS = %i[
16
+ queue require_approval rules auto_approve session_grants project_grants mode
17
+ ].freeze
18
+
13
19
  attr_reader :id, :chat, :tools, :turn_count, :created_at, :messages
14
20
  attr_reader :tool_calls_made, :total_input_tokens, :total_output_tokens, :total_cost
15
21
 
@@ -81,6 +87,7 @@ module Ask
81
87
  reflector: nil, telemetry: true, meta_agent: nil,
82
88
  agent_dir: nil, evaluator: nil, audit_log: nil,
83
89
  skills_disclosure: true, approval: nil,
90
+ session_grants: nil,
84
91
  tool_call_repair: nil, checkpoints: false,
85
92
  todos: false, plan_mode: false, memory: nil,
86
93
  memory_learning: false, offload_large_outputs: false,
@@ -88,6 +95,10 @@ module Ask
88
95
  **chat_options)
89
96
  @id = id || SecureRandom.uuid
90
97
  @agent_dir = agent_dir
98
+ @provided_session_grants = session_grants
99
+ @session_grants = nil
100
+ @project_grants = nil
101
+ @approval_policy = nil
91
102
  @max_turns = max_turns
92
103
  @max_tool_retries = max_tool_retries
93
104
  @parallel_tools = parallel_tools
@@ -201,7 +212,13 @@ module Ask
201
212
 
202
213
  # Plan gate runs before user hooks and the approval policy: in plan
203
214
  # mode, non-read-only tools are blocked outright (never queued).
215
+ # The allowed-tool decision lives in one shared PlanModePolicy;
216
+ # its #before_tool_call is hooked in only while plan mode is on.
204
217
  if @plan_mode
218
+ @plan_mode_policy = Ask::Permissions::PlanModePolicy.new(
219
+ allowed_tools: @plan_mode_read_only_tools,
220
+ exit_tool: "exit_plan_mode"
221
+ )
205
222
  @hooks = Hooks.new(
206
223
  before_tool: [method(:plan_mode_gate)] + Array(@hooks.instance_variable_get(:@before_tool)),
207
224
  after_tool: @hooks.instance_variable_get(:@after_tool)
@@ -275,6 +292,10 @@ module Ask
275
292
  # @return [Ask::Permissions::ApprovalQueue, nil] queue carrying plan
276
293
  # approvals (only when plan mode is enabled)
277
294
  attr_reader :plan_queue
295
+ # @return [Ask::Permissions::PlanModePolicy, nil] the shared policy
296
+ # deciding which tools may run in plan mode (only when plan mode
297
+ # is enabled)
298
+ attr_reader :plan_mode_policy
278
299
  # @return [Ask::Agent::TodoList, nil] session task list (only when
279
300
  # todos are enabled)
280
301
  attr_reader :todo_list
@@ -287,6 +308,7 @@ module Ask
287
308
  # @return [Ask::Agent::ArtifactStore, nil] store for tool deliverables
288
309
  # (only when the +artifacts:+ option is enabled)
289
310
  attr_reader :artifact_store
311
+ attr_reader :session_grants, :approval_policy
290
312
 
291
313
  def run(message, tools: nil, reset: true, attachments: nil, runtime_event_sink: nil)
292
314
  raise "Session deleted" if @deleted
@@ -512,6 +534,10 @@ module Ask
512
534
 
513
535
  data = deep_symbolize_keys(data)
514
536
 
537
+ approvals_snapshot = data[:approvals]
538
+ plan_snapshot = data[:plan_approvals]
539
+ grants_snapshot = data[:session_grants]
540
+
515
541
  session = new(
516
542
  id: data[:id],
517
543
  model: data.dig(:metadata, :model),
@@ -531,9 +557,14 @@ module Ask
531
557
  state: adapter,
532
558
  # Checkpointing is restored automatically when the session has
533
559
  # checkpoints in the store; todos likewise when the snapshot has
534
- # a task list.
560
+ # a task list. Approval / plan queues are re-enabled only when the
561
+ # persisted blob carries pending actions — policy config (rules,
562
+ # require_approval lists) is not persisted, only queue state that
563
+ # the shared Permissions API supports.
535
564
  checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?,
536
- todos: !data[:todos].nil?
565
+ todos: !data[:todos].nil?,
566
+ approval: (approval_snapshot_pending?(approvals_snapshot) || grants_snapshot_present?(grants_snapshot)) ? true : nil,
567
+ plan_mode: approval_snapshot_pending?(plan_snapshot) ? true : false
537
568
  )
538
569
 
539
570
  data[:messages].each do |msg|
@@ -544,7 +575,8 @@ module Ask
544
575
  )
545
576
  end
546
577
  session.instance_variable_get(:@todo_list)&.restore(data[:todos])
547
-
578
+ session.send(:restore_persisted_approvals, approvals_snapshot, plan_snapshot, grants_snapshot)
579
+
548
580
  session.instance_variable_set(:@messages, session.chat.messages.dup)
549
581
  session
550
582
  end
@@ -633,7 +665,8 @@ module Ask
633
665
  state: @state,
634
666
  checkpoints: true,
635
667
  todos: @todos_enabled,
636
- plan_mode: @plan_mode
668
+ plan_mode: @plan_mode,
669
+ approval: (@approval_queue || self.class.grants_snapshot_present?(data[:session_grants])) ? true : nil
637
670
  )
638
671
  restore_into(forked, data)
639
672
  emit(Events::SessionForked.new(session_id: @id, forked_id: forked_id, seq: seq))
@@ -701,13 +734,13 @@ end
701
734
  # phase; non-read-only tools are blocked until the plan is approved)
702
735
  def plan_mode? = @plan_mode
703
736
 
704
- # Before-tool gate active while in plan mode: only read-only tools
705
- # (and exit_plan_mode itself) run until a human approves the plan.
706
- def plan_mode_gate(tool_call, _context)
737
+ # Before-tool gate active while in plan mode: delegates the
738
+ # allowed-tool decision to the PlanModePolicy (read-only tools and
739
+ # exit_plan_mode itself) and proceeds once a human approves the plan.
740
+ def plan_mode_gate(tool_call, context)
707
741
  return { action: :proceed } unless @plan_mode
708
- return { action: :proceed } if @plan_mode_read_only_tools.include?(tool_call.name) || tool_call.name == "exit_plan_mode"
709
742
 
710
- { action: :block, reason: "Plan mode: only read-only tools until the plan is approved" }
743
+ @plan_mode_policy.before_tool_call(tool_call, context)
711
744
  end
712
745
 
713
746
  def approve_plan(action)
@@ -893,9 +926,19 @@ end
893
926
  #
894
927
  # `approval` accepts:
895
928
  # - true → queue with defaults
896
- # - a Hash → { require_approval:, auto_approve: } for the policy
897
- # - an ApprovalQueue → uses it, with policy options from
898
- # approval[:policy] if given
929
+ # - a Hash → { queue:, require_approval:, rules:,
930
+ # auto_approve:, session_grants:,
931
+ # project_grants:, mode: } — unknown keys
932
+ # raise; a nested `policy` option is not
933
+ # implemented
934
+ # - an ApprovalQueue → uses it directly: policy options are not
935
+ # read from the queue, so everything runs
936
+ # with defaults except the queue's own
937
+ # existing callbacks, which are preserved
938
+ #
939
+ # `mode:` forwards to Ask::Permissions::ApprovalPolicy — one of
940
+ # :full_access, :ask_before_changes, :read_only (nil = declaration-
941
+ # driven defaults).
899
942
  #
900
943
  # When enabled, an ApprovalPolicy hook is prepended to the session's
901
944
  # before_tool hooks so approval-required tools queue instead of running.
@@ -904,6 +947,12 @@ end
904
947
 
905
948
  policy_opts = approval.is_a?(Hash) ? approval : {}
906
949
 
950
+ unknown = policy_opts.keys - APPROVAL_OPTIONS
951
+ unless unknown.empty?
952
+ label = unknown.size == 1 ? "option" : "options"
953
+ raise ArgumentError, "unknown approval #{label}: #{unknown.map(&:inspect).join(", ")}"
954
+ end
955
+
907
956
  queue = if approval.is_a?(Ask::Permissions::ApprovalQueue)
908
957
  approval
909
958
  elsif policy_opts[:queue].is_a?(Ask::Permissions::ApprovalQueue)
@@ -916,10 +965,23 @@ end
916
965
  )
917
966
  end
918
967
 
919
- # Custom queues (subclasses, event-emitting wrappers) may come
920
- # without callbacks — wire the session's defaults so approvals
921
- # actually execute the tool call.
922
- queue.on_approve ||= ->(action) { apply_approved_action(action) }
968
+ custom_grants = @provided_session_grants || policy_opts[:session_grants]
969
+ unless custom_grants.nil? || custom_grants.is_a?(Ask::Permissions::SessionPermissionGrants)
970
+ raise ArgumentError, "session_grants must be an Ask::Permissions::SessionPermissionGrants, got #{custom_grants.class}"
971
+ end
972
+ @session_grants = custom_grants || Ask::Permissions::SessionPermissionGrants.new
973
+ @project_grants = policy_opts[:project_grants]
974
+ if @project_grants && !(%i[grant granted?].all? { |method| @project_grants.respond_to?(method) })
975
+ raise ArgumentError, "project_grants must respond to grant and granted?"
976
+ end
977
+
978
+ # Preserve a custom queue callback, but grant session scope before it
979
+ # executes. If no callback is supplied, keep the default executor.
980
+ previous_approve = queue.on_approve
981
+ queue.on_approve = lambda do |action|
982
+ apply_approval_scope(action)
983
+ previous_approve ? previous_approve.call(action) : apply_approved_action(action)
984
+ end
923
985
  queue.on_reject ||= ->(action) { reject_pending_action(action) }
924
986
  # Register the pending tool call the moment the action is queued —
925
987
  # before the auto-approval drain — so completions always match even
@@ -938,8 +1000,12 @@ end
938
1000
  queue: queue,
939
1001
  require_approval: policy_opts[:require_approval],
940
1002
  rules: policy_opts[:rules],
941
- tools: @tools
1003
+ tools: @tools,
1004
+ session_grants: @session_grants,
1005
+ project_grants: @project_grants,
1006
+ mode: policy_opts[:mode]
942
1007
  )
1008
+ @approval_policy = policy
943
1009
 
944
1010
  # Prepend the approval gate so it runs before user hooks
945
1011
  @hooks = Hooks.new(
@@ -989,6 +1055,17 @@ end
989
1055
  end
990
1056
  end
991
1057
 
1058
+ def apply_approval_scope(action)
1059
+ return unless action.respond_to?(:resolution_scope) && action.tool_name
1060
+
1061
+ case action.resolution_scope
1062
+ when :session
1063
+ @session_grants&.grant(action.tool_name.to_s)
1064
+ when :project
1065
+ @project_grants&.grant(action.tool_name.to_s)
1066
+ end
1067
+ end
1068
+
992
1069
  # Notify the conversation that an action was rejected by the user.
993
1070
  def reject_pending_action(action)
994
1071
  complete_pending_tool(
@@ -1098,15 +1175,133 @@ end
1098
1175
  target.instance_variable_set(:@messages, target.chat.messages.dup)
1099
1176
  target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
1100
1177
  target.instance_variable_get(:@todo_list)&.restore(data[:todos])
1178
+ # Approval + grant restore for checkpoint rollback/fork is silent,
1179
+ # but malformed permission snapshots raise instead of being stranded.
1180
+ target.send(:restore_persisted_approvals, data[:approvals], data[:plan_approvals], data[:session_grants])
1101
1181
  end
1102
1182
 
1103
1183
  # User-supplied tools only. Framework-injected tools (the built-in
1104
- # load_skill tool) are re-created by resolve_tools on every session, so
1184
+ # load_skill tool, todo_write, exit_plan_mode, memory tools,
1185
+ # output_read) are re-created by resolve_tools on every session, so
1105
1186
  # persisting them would leak framework internals into user data — and
1106
- # they cannot be auto-instantiated on load anyway (LoadSkillTool needs
1107
- # a registry).
1187
+ # they cannot always be auto-instantiated on load anyway (LoadSkillTool
1188
+ # needs a registry, ExitPlanMode needs a plan_queue).
1108
1189
  def persisted_tools
1109
- @tools.reject { |t| t.is_a?(Ask::Skills::LoadSkillTool) }
1190
+ @tools.reject do |t|
1191
+ t.is_a?(Ask::Skills::LoadSkillTool) ||
1192
+ (defined?(TodoWrite) && t.is_a?(TodoWrite)) ||
1193
+ (defined?(ExitPlanMode) && t.is_a?(ExitPlanMode)) ||
1194
+ (defined?(MemoryWrite) && t.is_a?(MemoryWrite)) ||
1195
+ (defined?(MemorySearch) && t.is_a?(MemorySearch)) ||
1196
+ (defined?(OutputRead) && t.is_a?(OutputRead))
1197
+ end
1198
+ end
1199
+
1200
+ # JSON-safe v1 snapshot for a queue, or nil when there is no queue or
1201
+ # it predates the shared Permissions API (compatibility fallback:
1202
+ # nothing durable to persist, so nothing to restore — cannot strand
1203
+ # pendings because an unsupported queue never contributed durable
1204
+ # state). Raises Ask::Agent::Error with context when the queue
1205
+ # supports the API but snapshotting fails.
1206
+ def queue_snapshot_for(queue, queue_name = "approval")
1207
+ return nil unless queue
1208
+ return nil unless queue.respond_to?(:snapshot)
1209
+
1210
+ begin
1211
+ queue.snapshot
1212
+ rescue StandardError => e
1213
+ raise Ask::Agent::Error, "Failed to snapshot #{queue_name} queue (#{queue.class}): #{e.class}: #{e.message}"
1214
+ end
1215
+ end
1216
+
1217
+ # Restore persisted queue snapshots into this session's queues without
1218
+ # firing callbacks or emitting events. Pending-tool registrations are
1219
+ # rebuilt silently so a later approve/reject still completes the
1220
+ # original tool call exactly once.
1221
+ #
1222
+ # Compatibility fallback (cannot strand): a nil snapshot (approval off
1223
+ # or unsupported queue) and an empty pending_actions list are safe
1224
+ # no-ops. Any other state that would prevent a faithful restore —
1225
+ # non-Hash snapshot, missing/non-Array pendings, missing queue or
1226
+ # queue without restore support while pendings exist, a non-empty
1227
+ # target queue, or a restore_pending failure — raises
1228
+ # Ask::Agent::Error with queue context instead of silently dropping
1229
+ # pending actions.
1230
+ def restore_persisted_approvals(approvals_snapshot, plan_snapshot, grants_snapshot = nil)
1231
+ restore_queue_snapshot(@approval_queue, approvals_snapshot, "approval")
1232
+ restore_queue_snapshot(@plan_queue, plan_snapshot, "plan")
1233
+ restore_grants_snapshot(@session_grants, grants_snapshot)
1234
+ nil
1235
+ end
1236
+
1237
+ def restore_grants_snapshot(grants, snapshot)
1238
+ return if snapshot.nil?
1239
+ raise Ask::Agent::Error, "Cannot restore session grants: snapshot must be a Hash" unless snapshot.is_a?(Hash)
1240
+
1241
+ tools = snapshot[:granted_tools] || snapshot["granted_tools"]
1242
+ unless tools.is_a?(Array) && tools.all? { |tool| tool.is_a?(String) && !tool.empty? }
1243
+ raise Ask::Agent::Error, "Cannot restore session grants: granted_tools must be an Array of non-empty Strings"
1244
+ end
1245
+ return if tools.empty?
1246
+ raise Ask::Agent::Error, "Cannot restore session grants: session has no grants store" unless grants
1247
+
1248
+ begin
1249
+ grants.restore_snapshot(snapshot)
1250
+ rescue StandardError => e
1251
+ raise Ask::Agent::Error, "Cannot restore session grants: #{e.class}: #{e.message}"
1252
+ end
1253
+ end
1254
+
1255
+ def restore_queue_snapshot(queue, snapshot, queue_name = "approval")
1256
+ return nil if snapshot.nil?
1257
+
1258
+ unless snapshot.is_a?(Hash)
1259
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: snapshot must be a Hash, got #{snapshot.class}"
1260
+ end
1261
+
1262
+ pendings = snapshot[:pending_actions] || snapshot["pending_actions"]
1263
+ if pendings.nil?
1264
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: snapshot missing pending_actions"
1265
+ end
1266
+ unless pendings.is_a?(Array)
1267
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: pending_actions must be an Array, got #{pendings.class}"
1268
+ end
1269
+ return nil if pendings.empty?
1270
+
1271
+ unless queue
1272
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: session has no #{queue_name} queue but snapshot carries #{pendings.size} pending action(s)"
1273
+ end
1274
+ unless queue.respond_to?(:restore_pending) && queue.respond_to?(:pending_actions)
1275
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: #{queue.class} does not support restore_pending"
1276
+ end
1277
+
1278
+ # restore_pending requires an empty queue — a non-empty target means
1279
+ # a second restore would duplicate or strand actions, so fail loudly
1280
+ # instead of silently dropping either side.
1281
+ if queue.respond_to?(:any_pending?) && queue.any_pending?
1282
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: target queue already holds pending actions"
1283
+ end
1284
+
1285
+ begin
1286
+ queue.restore_pending(snapshot)
1287
+ rescue StandardError => e
1288
+ raise Ask::Agent::Error, "Cannot restore #{queue_name} queue: #{e.class}: #{e.message}"
1289
+ end
1290
+ queue.pending_actions.each do |action|
1291
+ tool_call_id = action.tool_call_id
1292
+ next unless tool_call_id
1293
+
1294
+ @pending_mutex.synchronize do
1295
+ @pending_tools[tool_call_id] ||= {
1296
+ tool_name: action.tool_name,
1297
+ message: action.message || "Pending approval",
1298
+ status: "pending",
1299
+ tool_call_id: tool_call_id,
1300
+ action_id: action.id
1301
+ }
1302
+ end
1303
+ end
1304
+ nil
1110
1305
  end
1111
1306
 
1112
1307
  # Persist content blocks as their +to_h+ hashes so attachments
@@ -1134,6 +1329,14 @@ end
1134
1329
  }
1135
1330
  },
1136
1331
  todos: @todo_list&.to_h,
1332
+ # Durable permission state: JSON-safe v1 queue snapshots when the
1333
+ # queue supports the shared Permissions API (#snapshot). Nil when
1334
+ # approval is off or the queue predates the API (compatibility
1335
+ # fallback — nothing durable to persist, so nothing to strand).
1336
+ # Snapshot failures raise Ask::Agent::Error with queue context.
1337
+ approvals: queue_snapshot_for(@approval_queue, "approval"),
1338
+ plan_approvals: queue_snapshot_for(@plan_queue, "plan"),
1339
+ session_grants: @session_grants&.snapshot,
1137
1340
  metadata: {
1138
1341
  model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
1139
1342
  tools: persisted_tools.map { |t| t.class.name },
@@ -1245,6 +1448,22 @@ end
1245
1448
  end
1246
1449
  end
1247
1450
 
1451
+ # True when a persisted approval snapshot carries at least one pending
1452
+ # action. Used to decide whether Session.load should re-enable the
1453
+ # approval / plan queue — policy config itself is never persisted.
1454
+ def self.approval_snapshot_pending?(snapshot)
1455
+ return false unless snapshot.is_a?(Hash)
1456
+
1457
+ pendings = snapshot[:pending_actions] || snapshot["pending_actions"]
1458
+ pendings.is_a?(Array) && !pendings.empty?
1459
+ end
1460
+
1461
+ def self.grants_snapshot_present?(snapshot)
1462
+ return false unless snapshot.is_a?(Hash)
1463
+ tools = snapshot[:granted_tools] || snapshot["granted_tools"]
1464
+ tools.is_a?(Array) && !tools.empty?
1465
+ end
1466
+
1248
1467
  # Render the system context and apply it to the chat.
1249
1468
  def apply_system_context
1250
1469
  rendered = @system_context.render
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.40.30"
5
+ VERSION = "0.42.0"
6
6
  end
7
7
  end
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.40.30
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -141,14 +141,14 @@ dependencies:
141
141
  requirements:
142
142
  - - ">="
143
143
  - !ruby/object:Gem::Version
144
- version: 0.1.0
144
+ version: 0.2.0
145
145
  type: :runtime
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
149
  - - ">="
150
150
  - !ruby/object:Gem::Version
151
- version: 0.1.0
151
+ version: 0.2.0
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: minitest
154
154
  requirement: !ruby/object:Gem::Requirement