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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +29 -0
- data/lib/ask/agent/session.rb +240 -21
- data/lib/ask/agent/session_adapter.rb +119 -1
- data/lib/ask/agent/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa59129858a82b94badc3ec8e378457e2de2d0ffca3d5814797bc5e5ac0bb25d
|
|
4
|
+
data.tar.gz: cb266f3ff5e66ddc0227fd609d8ba99141190dd433ffe7ef5ab221a620cbcda8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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:
|
|
705
|
-
#
|
|
706
|
-
|
|
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
|
-
|
|
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:,
|
|
897
|
-
#
|
|
898
|
-
#
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/ask/agent/version.rb
CHANGED
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.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.
|
|
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.
|
|
151
|
+
version: 0.2.0
|
|
152
152
|
- !ruby/object:Gem::Dependency
|
|
153
153
|
name: minitest
|
|
154
154
|
requirement: !ruby/object:Gem::Requirement
|