ask-agent 0.30.1 → 0.32.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 +50 -0
- data/lib/ask/agent/events.rb +8 -0
- data/lib/ask/agent/exit_plan_mode.rb +47 -0
- data/lib/ask/agent/policies/approval_policy.rb +24 -6
- data/lib/ask/agent/policies/permission_rules.rb +130 -0
- data/lib/ask/agent/session.rb +107 -11
- data/lib/ask/agent/todo_list.rb +132 -0
- data/lib/ask/agent/todo_write.rb +59 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad75fe0e23adeabc49b068df1916a102d7122710dfe5a45471ba68b47864d75c
|
|
4
|
+
data.tar.gz: 4f7381343e7d63a4d2b4ffe9e70cf93c92ab941ccdf11dad52d6ff65bbe227cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86a1695d69a497b6ec0fd5b57c942084330a70b35b8bf5fd3340db03ce186be98350eec974c1aaec075e4ef30c8b79628d8c3028d3c64fdb288619f27ff58fda
|
|
7
|
+
data.tar.gz: b771e752c640319a5de1bbe63f9dec8079e62a9f8d371c8e82b90898e9af303528739c62c852f83cda58513296edd4ffe9372e090f189914149ab417d26bb254
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
## [0.32.0] — 2026-08-06
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **TodoWrite — the model maintains a live task list.** `Session.new(todos:
|
|
6
|
+
true)` injects a `todo_write` tool backed by a session-scoped
|
|
7
|
+
`Ask::Agent::TodoList`:
|
|
8
|
+
- Actions `add` / `update` / `list` / `clear` with `pending`,
|
|
9
|
+
`in_progress`, `completed`, `blocked` statuses; every result returns
|
|
10
|
+
the full list so one call both mutates and shows state.
|
|
11
|
+
- `Events::TodoUpdated` fires with the full list on every change — the
|
|
12
|
+
contract for live progress rendering (UI kit / app server).
|
|
13
|
+
- The list is part of the checkpoint snapshot: `rollback!` and `fork`
|
|
14
|
+
restore it, and `Session.load` re-enables todos automatically.
|
|
15
|
+
- **Plan mode — research first, execute after human approval.** `Session.new(
|
|
16
|
+
plan_mode: true)` (or `plan_mode: { read_only_tools: [...] }`) starts the
|
|
17
|
+
session in a research phase where non-read-only tools are blocked
|
|
18
|
+
(`:block` with a plan-mode reason; the gate runs before user hooks and
|
|
19
|
+
the approval policy). The model researches, then calls the injected
|
|
20
|
+
`exit_plan_mode` tool with its plan:
|
|
21
|
+
- The plan is submitted to a dedicated `Session#plan_queue` and the tool
|
|
22
|
+
returns a pending result — the agent hands back the interim reply,
|
|
23
|
+
exactly like the tool-approval flow.
|
|
24
|
+
- **Approve** → plan mode turns off, `Events::PlanApproved` fires, and a
|
|
25
|
+
follow-up turn executes the plan. **Reject** → the session stays in
|
|
26
|
+
plan mode, `Events::PlanRejected` fires, and the rejection feedback
|
|
27
|
+
reaches the conversation.
|
|
28
|
+
- Default read-only allowlist: `read`, `glob`, `grep`, `web_search`.
|
|
29
|
+
|
|
30
|
+
## [0.31.0] — 2026-08-06
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Permission rules — persisted allow/ask/deny patterns for tool calls.**
|
|
35
|
+
`Ask::Agent::Policies::PermissionRules` classifies every call before it
|
|
36
|
+
executes or prompts, so "approve once, remember the pattern" replaces
|
|
37
|
+
per-call prompting:
|
|
38
|
+
- DSL in declaration order (first match wins): `allow`, `ask`, `deny`
|
|
39
|
+
with a tool pattern (String, Symbol, Regexp, or `:all`) and an optional
|
|
40
|
+
argument pattern (Regexp, substring, or `nil` for any).
|
|
41
|
+
- Wire in via `Session.new(approval: { rules: rules })`. Rules take
|
|
42
|
+
precedence over a tool's own `approval_required` / `auto_approvable`
|
|
43
|
+
declarations: `:deny` blocks, `:allow` proceeds without the queue,
|
|
44
|
+
`:ask` queues regardless of auto-approvable.
|
|
45
|
+
- **Dangerous-rule guard**: an unrestricted `:allow` on a code-executing
|
|
46
|
+
tool (`bash`, `code`, `repl`, or `:all`) is downgraded to `:ask` unless
|
|
47
|
+
the ruleset is created with `auto_allow_dangerous: true` — "approve
|
|
48
|
+
once" can't become "approve anything". `dangerous_rules` reports which
|
|
49
|
+
rules were affected.
|
|
50
|
+
|
|
1
51
|
## [0.30.1] — 2026-08-06
|
|
2
52
|
|
|
3
53
|
### Fixed
|
data/lib/ask/agent/events.rb
CHANGED
|
@@ -34,6 +34,14 @@ module Ask
|
|
|
34
34
|
# Emitted when a session was forked from a checkpoint.
|
|
35
35
|
SessionForked = Data.define(:session_id, :forked_id, :seq)
|
|
36
36
|
|
|
37
|
+
# Emitted when the task list changed (todo_write tool); carries the
|
|
38
|
+
# full entry list for live rendering.
|
|
39
|
+
TodoUpdated = Data.define(:todos)
|
|
40
|
+
# Plan mode lifecycle.
|
|
41
|
+
PlanProposed = Data.define(:plan)
|
|
42
|
+
PlanApproved = Data.define(:plan)
|
|
43
|
+
PlanRejected = Data.define(:plan)
|
|
44
|
+
|
|
37
45
|
LoopDetected = Data.define(:tool_name, :repeated_count)
|
|
38
46
|
MaxTurnsExceeded = Data.define(:max_turns)
|
|
39
47
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask/tools/tool"
|
|
4
|
+
require "ask/result"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Agent
|
|
8
|
+
# Presents the model's plan for human approval at the end of plan mode.
|
|
9
|
+
#
|
|
10
|
+
# Called after research: submits the plan to the session's plan queue
|
|
11
|
+
# and returns a pending result — the agent hands back the interim reply
|
|
12
|
+
# and waits for a human decision. On approval, plan mode turns off and
|
|
13
|
+
# the agent executes the plan; on rejection, it stays in plan mode with
|
|
14
|
+
# the rejection feedback in the conversation.
|
|
15
|
+
#
|
|
16
|
+
# Injected into the session by `Session.new(plan_mode: true)`.
|
|
17
|
+
class ExitPlanMode < Ask::Tool
|
|
18
|
+
description "Submit your plan for human approval and leave plan mode. " \
|
|
19
|
+
"Call this when your research is done and you are ready to execute."
|
|
20
|
+
|
|
21
|
+
param :plan, type: :string, desc: "The plan you propose to execute", required: true
|
|
22
|
+
|
|
23
|
+
# @param plan_queue [Ask::Agent::ApprovalQueue] queue carrying plan
|
|
24
|
+
# approvals; the session wires approve/reject callbacks
|
|
25
|
+
# @param on_submit [Proc, nil] called with the plan text when the plan
|
|
26
|
+
# is submitted (used to emit PlanProposed)
|
|
27
|
+
def initialize(plan_queue:, on_submit: nil)
|
|
28
|
+
@plan_queue = plan_queue
|
|
29
|
+
@on_submit = on_submit
|
|
30
|
+
super()
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def execute(plan:)
|
|
34
|
+
tool_call_id = Thread.current[:ask_agent_tool_call_id]
|
|
35
|
+
@plan_queue.submit(
|
|
36
|
+
tool_call_id: tool_call_id,
|
|
37
|
+
tool_name: "exit_plan_mode",
|
|
38
|
+
args: { plan: plan.to_s },
|
|
39
|
+
auto_approvable: false,
|
|
40
|
+
message: "Plan submitted for approval"
|
|
41
|
+
)
|
|
42
|
+
@on_submit&.call(plan.to_s)
|
|
43
|
+
Ask::Result.pending("Plan submitted for approval — waiting for a human decision")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -32,13 +32,18 @@ module Ask
|
|
|
32
32
|
# rule-based classification on top of the tool's own declaration.
|
|
33
33
|
# Strings match tool names exactly, Regexps match against the name,
|
|
34
34
|
# and :all requires approval for every tool call.
|
|
35
|
+
# @param rules [Ask::Agent::Policies::PermissionRules, nil] persisted
|
|
36
|
+
# allow/ask/deny patterns. Rules classify first and take precedence
|
|
37
|
+
# over tool declarations: :deny blocks, :allow proceeds without the
|
|
38
|
+
# queue, :ask queues regardless of auto-approvable.
|
|
35
39
|
# @param tools [Array<Object>, nil] the session's resolved tool
|
|
36
40
|
# instances, used to read class-level declarations
|
|
37
41
|
# (`approval_required`, `auto_approvable`). When nil, only the
|
|
38
42
|
# rule-based lists classify calls.
|
|
39
|
-
def initialize(queue:, require_approval: nil, tools: nil)
|
|
43
|
+
def initialize(queue:, require_approval: nil, rules: nil, tools: nil)
|
|
40
44
|
@queue = queue
|
|
41
45
|
@require_approval = require_approval
|
|
46
|
+
@rules = rules
|
|
42
47
|
@tools = Array(tools)
|
|
43
48
|
end
|
|
44
49
|
|
|
@@ -46,12 +51,27 @@ module Ask
|
|
|
46
51
|
#
|
|
47
52
|
# @param tool_call [Ask::Agent::ToolCallInfo]
|
|
48
53
|
# @param _context [Hash]
|
|
49
|
-
# @return [Hash] {action: :proceed} to run,
|
|
50
|
-
#
|
|
54
|
+
# @return [Hash] {action: :proceed} to run, {action: :pending,
|
|
55
|
+
# action_id:, reason:} to queue for approval, or {action: :block,
|
|
56
|
+
# reason:} to refuse
|
|
51
57
|
def before_tool_call(tool_call, _context)
|
|
58
|
+
case @rules&.classify(tool_call.name, tool_call.arguments)
|
|
59
|
+
when :deny
|
|
60
|
+
return { action: :block, reason: "Denied by permission rules: '#{tool_call.name}'" }
|
|
61
|
+
when :allow
|
|
62
|
+
return { action: :proceed }
|
|
63
|
+
when :ask
|
|
64
|
+
return queue_for_approval(tool_call, auto_approvable: false)
|
|
65
|
+
end
|
|
66
|
+
|
|
52
67
|
return { action: :proceed } unless approval_required?(tool_call.name)
|
|
53
68
|
|
|
54
|
-
auto_approvable
|
|
69
|
+
queue_for_approval(tool_call, auto_approvable: tool_auto_approvable?(tool_call.name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def queue_for_approval(tool_call, auto_approvable:)
|
|
55
75
|
action_id = @queue.submit(
|
|
56
76
|
tool_call_id: tool_call.id,
|
|
57
77
|
tool_name: tool_call.name,
|
|
@@ -63,8 +83,6 @@ module Ask
|
|
|
63
83
|
{ action: :pending, action_id: action_id, reason: "Tool '#{tool_call.name}' requires approval" }
|
|
64
84
|
end
|
|
65
85
|
|
|
66
|
-
private
|
|
67
|
-
|
|
68
86
|
def approval_required?(tool_name)
|
|
69
87
|
return true if @require_approval == :all
|
|
70
88
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
module Policies
|
|
8
|
+
# Persisted, matchable allow/ask/deny patterns for tool calls.
|
|
9
|
+
#
|
|
10
|
+
# Rules classify a tool call before it executes or prompts:
|
|
11
|
+
#
|
|
12
|
+
# rules = Ask::Agent::Policies::PermissionRules.new do |r|
|
|
13
|
+
# r.allow :bash, /^git (pull|push|status)/
|
|
14
|
+
# r.ask :bash, /^rm -rf/
|
|
15
|
+
# r.deny :write, %r{/\.env(\.local)?$}
|
|
16
|
+
# r.ask :destroy, :all
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# session = Ask::Agent::Session.new(
|
|
20
|
+
# model: "gpt-4o",
|
|
21
|
+
# approval: { rules: rules }
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# Classification is first-match-wins, in declaration order: :allow runs
|
|
25
|
+
# the tool, :ask queues it for human approval, :deny blocks it. Rules
|
|
26
|
+
# take precedence over a tool's own `approval_required` / `auto_approvable`
|
|
27
|
+
# declarations — they are explicit user intent.
|
|
28
|
+
#
|
|
29
|
+
# Dangerous-rule guard: an :allow rule for a code-executing tool (bash,
|
|
30
|
+
# code, repl) whose argument pattern is unrestricted would let the model
|
|
31
|
+
# run anything without asking. Such rules are downgraded to :ask unless
|
|
32
|
+
# the ruleset was created with `auto_allow_dangerous: true` — "approve
|
|
33
|
+
# once, remember the pattern" must not become "approve everything".
|
|
34
|
+
class PermissionRules
|
|
35
|
+
# A single rule: tool pattern + optional argument pattern + decision.
|
|
36
|
+
Rule = Data.define(:tool_pattern, :argument_pattern, :decision) do
|
|
37
|
+
def matches?(tool_name, arguments)
|
|
38
|
+
tool_matches?(tool_name) && argument_matches?(arguments)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tool_matches?(tool_name)
|
|
42
|
+
case tool_pattern
|
|
43
|
+
when :all then true
|
|
44
|
+
when Regexp then tool_pattern.match?(tool_name.to_s)
|
|
45
|
+
else tool_pattern.to_s == tool_name.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def argument_matches?(arguments)
|
|
50
|
+
return true if argument_pattern.nil?
|
|
51
|
+
|
|
52
|
+
text = arguments.is_a?(Hash) ? JSON.generate(arguments) : arguments.to_s
|
|
53
|
+
case argument_pattern
|
|
54
|
+
when Regexp then argument_pattern.match?(text)
|
|
55
|
+
else text.include?(argument_pattern.to_s)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def universal?
|
|
60
|
+
argument_pattern.nil?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Tools that execute arbitrary code — an unrestricted :allow rule on
|
|
65
|
+
# any of these is dangerous.
|
|
66
|
+
DANGEROUS_TOOLS = %i[bash code repl].freeze
|
|
67
|
+
|
|
68
|
+
# @param auto_allow_dangerous [Boolean] keep unrestricted :allow
|
|
69
|
+
# rules on code-executing tools (default false — they downgrade to
|
|
70
|
+
# :ask)
|
|
71
|
+
def initialize(auto_allow_dangerous: false, &block)
|
|
72
|
+
@auto_allow_dangerous = auto_allow_dangerous
|
|
73
|
+
@rules = []
|
|
74
|
+
instance_eval(&block) if block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# DSL — declare rules in priority order (first match wins).
|
|
78
|
+
def allow(tool_pattern, argument_pattern = nil)
|
|
79
|
+
add(:allow, tool_pattern, argument_pattern)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ask(tool_pattern, argument_pattern = nil)
|
|
83
|
+
add(:ask, tool_pattern, argument_pattern)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def deny(tool_pattern, argument_pattern = nil)
|
|
87
|
+
add(:deny, tool_pattern, argument_pattern)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Array<Rule>] declared rules, in order
|
|
91
|
+
def rules = @rules.dup
|
|
92
|
+
|
|
93
|
+
# Classify a tool call.
|
|
94
|
+
#
|
|
95
|
+
# @param tool_name [String]
|
|
96
|
+
# @param arguments [Hash, String, nil] tool arguments (hash or JSON)
|
|
97
|
+
# @return [Symbol, nil] :allow, :ask, :deny — or nil when no rule
|
|
98
|
+
# matches
|
|
99
|
+
def classify(tool_name, arguments = nil)
|
|
100
|
+
rule = @rules.find { |r| r.matches?(tool_name, arguments) }
|
|
101
|
+
return nil unless rule
|
|
102
|
+
|
|
103
|
+
if rule.decision == :allow && dangerous?(rule) && !@auto_allow_dangerous
|
|
104
|
+
:ask
|
|
105
|
+
else
|
|
106
|
+
rule.decision
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Array<Rule>] rules that would allow unrestricted execution
|
|
111
|
+
# of a code-executing tool
|
|
112
|
+
def dangerous_rules
|
|
113
|
+
@rules.select { |r| dangerous?(r) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def add(decision, tool_pattern, argument_pattern)
|
|
119
|
+
@rules << Rule.new(tool_pattern, argument_pattern, decision)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def dangerous?(rule)
|
|
123
|
+
return false unless rule.decision == :allow && rule.universal?
|
|
124
|
+
|
|
125
|
+
rule.tool_pattern == :all || DANGEROUS_TOOLS.any? { |t| rule.tool_matches?(t) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -23,7 +23,8 @@ module Ask
|
|
|
23
23
|
reflector: nil, telemetry: true, meta_agent: nil,
|
|
24
24
|
agent_dir: nil, evaluator: nil, audit_log: nil,
|
|
25
25
|
skills_disclosure: true, approval: nil,
|
|
26
|
-
tool_call_repair: nil, checkpoints: false,
|
|
26
|
+
tool_call_repair: nil, checkpoints: false,
|
|
27
|
+
todos: false, plan_mode: false, **chat_options)
|
|
27
28
|
@id = id || SecureRandom.uuid
|
|
28
29
|
@agent_dir = agent_dir
|
|
29
30
|
@max_turns = max_turns
|
|
@@ -47,6 +48,25 @@ module Ask
|
|
|
47
48
|
|
|
48
49
|
@telemetry = telemetry.is_a?(Telemetry) ? telemetry : Telemetry.new(enabled: !!telemetry)
|
|
49
50
|
|
|
51
|
+
# Task list (todo_write tool) — built before resolve_tools so the
|
|
52
|
+
# tool can be injected with a reference to it.
|
|
53
|
+
@todos_enabled = !!todos
|
|
54
|
+
@todo_list = TodoList.new if @todos_enabled
|
|
55
|
+
@todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) }
|
|
56
|
+
|
|
57
|
+
# Plan mode — research phase gated to read-only tools until a human
|
|
58
|
+
# approves the model's plan (submitted via the exit_plan_mode tool).
|
|
59
|
+
@plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
|
|
60
|
+
@plan_mode_read_only_tools = if plan_mode.is_a?(Hash) && plan_mode[:read_only_tools]
|
|
61
|
+
Array(plan_mode[:read_only_tools]).map(&:to_s)
|
|
62
|
+
else
|
|
63
|
+
%w[read glob grep web_search]
|
|
64
|
+
end
|
|
65
|
+
@plan_queue = ApprovalQueue.new(
|
|
66
|
+
on_approve: ->(action) { approve_plan(action) },
|
|
67
|
+
on_reject: ->(action) { reject_plan(action) }
|
|
68
|
+
) if @plan_mode
|
|
69
|
+
|
|
50
70
|
@tools = resolve_tools(tools)
|
|
51
71
|
@chat = build_chat(model, system_prompt, @tools, **chat_options)
|
|
52
72
|
@loop = Loop.new(max_turns: max_turns)
|
|
@@ -57,6 +77,15 @@ module Ask
|
|
|
57
77
|
@approval_queue = build_approval(approval)
|
|
58
78
|
@tool_call_repair = tool_call_repair
|
|
59
79
|
|
|
80
|
+
# Plan gate runs before user hooks and the approval policy: in plan
|
|
81
|
+
# mode, non-read-only tools are blocked outright (never queued).
|
|
82
|
+
if @plan_mode
|
|
83
|
+
@hooks = Hooks.new(
|
|
84
|
+
before_tool: [method(:plan_mode_gate)] + Array(@hooks.instance_variable_get(:@before_tool)),
|
|
85
|
+
after_tool: @hooks.instance_variable_get(:@after_tool)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
60
89
|
@system_context = build_system_context(system_prompt)
|
|
61
90
|
apply_system_context
|
|
62
91
|
|
|
@@ -102,6 +131,12 @@ module Ask
|
|
|
102
131
|
#
|
|
103
132
|
# @return [Ask::Agent::ApprovalQueue, nil]
|
|
104
133
|
attr_reader :approval_queue
|
|
134
|
+
# @return [Ask::Agent::ApprovalQueue, nil] queue carrying plan
|
|
135
|
+
# approvals (only when plan mode is enabled)
|
|
136
|
+
attr_reader :plan_queue
|
|
137
|
+
# @return [Ask::Agent::TodoList, nil] session task list (only when
|
|
138
|
+
# todos are enabled)
|
|
139
|
+
attr_reader :todo_list
|
|
105
140
|
|
|
106
141
|
def run(message, tools: nil, reset: true)
|
|
107
142
|
raise "Session deleted" if @deleted
|
|
@@ -329,17 +364,20 @@ module Ask
|
|
|
329
364
|
end,
|
|
330
365
|
state: adapter,
|
|
331
366
|
# Checkpointing is restored automatically when the session has
|
|
332
|
-
# checkpoints in the store
|
|
333
|
-
|
|
367
|
+
# checkpoints in the store; todos likewise when the snapshot has
|
|
368
|
+
# a task list.
|
|
369
|
+
checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?,
|
|
370
|
+
todos: !data[:todos].nil?
|
|
334
371
|
)
|
|
335
372
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
373
|
+
data[:messages].each do |msg|
|
|
374
|
+
session.chat.add_message(
|
|
375
|
+
role: msg[:role].to_sym,
|
|
376
|
+
content: msg[:content],
|
|
377
|
+
tool_call_id: msg[:tool_call_id]
|
|
378
|
+
)
|
|
379
|
+
end
|
|
380
|
+
session.instance_variable_get(:@todo_list)&.restore(data[:todos])
|
|
343
381
|
|
|
344
382
|
session.instance_variable_set(:@messages, session.chat.messages.dup)
|
|
345
383
|
session
|
|
@@ -425,13 +463,59 @@ module Ask
|
|
|
425
463
|
model: data[:metadata][:model],
|
|
426
464
|
tools: @tools,
|
|
427
465
|
state: @state,
|
|
428
|
-
checkpoints: true
|
|
466
|
+
checkpoints: true,
|
|
467
|
+
todos: @todos_enabled,
|
|
468
|
+
plan_mode: @plan_mode
|
|
429
469
|
)
|
|
430
470
|
restore_into(forked, data)
|
|
431
471
|
emit(Events::SessionForked.new(session_id: @id, forked_id: forked_id, seq: seq))
|
|
432
472
|
forked
|
|
433
473
|
end
|
|
434
474
|
|
|
475
|
+
# --- Plan mode ---
|
|
476
|
+
|
|
477
|
+
# @return [Boolean] whether the session is in plan mode (research
|
|
478
|
+
# phase; non-read-only tools are blocked until the plan is approved)
|
|
479
|
+
def plan_mode? = @plan_mode
|
|
480
|
+
|
|
481
|
+
# Before-tool gate active while in plan mode: only read-only tools
|
|
482
|
+
# (and exit_plan_mode itself) run until a human approves the plan.
|
|
483
|
+
def plan_mode_gate(tool_call, _context)
|
|
484
|
+
return { action: :proceed } unless @plan_mode
|
|
485
|
+
return { action: :proceed } if @plan_mode_read_only_tools.include?(tool_call.name) || tool_call.name == "exit_plan_mode"
|
|
486
|
+
|
|
487
|
+
{ action: :block, reason: "Plan mode: only read-only tools until the plan is approved" }
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def approve_plan(action)
|
|
491
|
+
@plan_mode = false
|
|
492
|
+
plan = action.args[:plan] || action.args["plan"] || ""
|
|
493
|
+
emit(Events::PlanApproved.new(plan: plan))
|
|
494
|
+
complete_pending_tool(
|
|
495
|
+
tool_call_id: action.tool_call_id,
|
|
496
|
+
result: {
|
|
497
|
+
tool_name: "exit_plan_mode",
|
|
498
|
+
message: "Plan approved — execute it now.",
|
|
499
|
+
status: "success",
|
|
500
|
+
is_error: false
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def reject_plan(action)
|
|
506
|
+
plan = action.args[:plan] || action.args["plan"] || ""
|
|
507
|
+
emit(Events::PlanRejected.new(plan: plan))
|
|
508
|
+
complete_pending_tool(
|
|
509
|
+
tool_call_id: action.tool_call_id,
|
|
510
|
+
result: {
|
|
511
|
+
tool_name: "exit_plan_mode",
|
|
512
|
+
message: "Plan rejected by the user — revise your plan and resubmit.",
|
|
513
|
+
status: "rejected",
|
|
514
|
+
is_error: false
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
end
|
|
518
|
+
|
|
435
519
|
# --- Async (pending) tools ---
|
|
436
520
|
|
|
437
521
|
# Registers a pending tool call (called by the loop when a tool
|
|
@@ -555,6 +639,7 @@ module Ask
|
|
|
555
639
|
policy = Ask::Agent::Policies::ApprovalPolicy.new(
|
|
556
640
|
queue: queue,
|
|
557
641
|
require_approval: policy_opts[:require_approval],
|
|
642
|
+
rules: policy_opts[:rules],
|
|
558
643
|
tools: @tools
|
|
559
644
|
)
|
|
560
645
|
|
|
@@ -630,6 +715,15 @@ module Ask
|
|
|
630
715
|
if skills_disclosure_enabled?
|
|
631
716
|
resolved << Ask::Skills::LoadSkillTool.new(registry: @skills_registry) unless resolved.any? { |t| t.name == "load_skill" }
|
|
632
717
|
end
|
|
718
|
+
if @todo_list
|
|
719
|
+
resolved << TodoWrite.new(todo_list: @todo_list) unless resolved.any? { |t| t.name == "todo_write" }
|
|
720
|
+
end
|
|
721
|
+
if @plan_mode
|
|
722
|
+
resolved << ExitPlanMode.new(
|
|
723
|
+
plan_queue: @plan_queue,
|
|
724
|
+
on_submit: ->(plan) { emit(Events::PlanProposed.new(plan: plan)) }
|
|
725
|
+
) unless resolved.any? { |t| t.name == "exit_plan_mode" }
|
|
726
|
+
end
|
|
633
727
|
resolved
|
|
634
728
|
end
|
|
635
729
|
|
|
@@ -694,6 +788,7 @@ module Ask
|
|
|
694
788
|
end
|
|
695
789
|
target.instance_variable_set(:@messages, target.chat.messages.dup)
|
|
696
790
|
target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
|
|
791
|
+
target.instance_variable_get(:@todo_list)&.restore(data[:todos])
|
|
697
792
|
end
|
|
698
793
|
|
|
699
794
|
# User-supplied tools only. Framework-injected tools (the built-in
|
|
@@ -716,6 +811,7 @@ module Ask
|
|
|
716
811
|
created_at: Time.now.iso8601
|
|
717
812
|
}
|
|
718
813
|
},
|
|
814
|
+
todos: @todo_list&.to_h,
|
|
719
815
|
metadata: {
|
|
720
816
|
model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
|
|
721
817
|
tools: persisted_tools.map { |t| t.class.name },
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
# Session-scoped task list maintained by the model through the
|
|
6
|
+
# {TodoWrite} tool.
|
|
7
|
+
#
|
|
8
|
+
# The list is the externalized plan: the model writes it once and checks
|
|
9
|
+
# it against each step, humans see progress live (via the TodoUpdated
|
|
10
|
+
# event), and checkpoints carry it across rollbacks and forks.
|
|
11
|
+
class TodoList
|
|
12
|
+
STATUSES = %w[pending in_progress completed blocked].freeze
|
|
13
|
+
|
|
14
|
+
Entry = Data.define(:id, :title, :status) do
|
|
15
|
+
def to_h = { id: id, title: title, status: status }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@entries = []
|
|
20
|
+
@next_id = 1
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@listeners = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<Entry>] snapshot of the current entries
|
|
26
|
+
def all
|
|
27
|
+
@mutex.synchronize { @entries.dup }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register a listener called with the full entry list after every
|
|
31
|
+
# change (used to emit TodoUpdated events for live rendering).
|
|
32
|
+
#
|
|
33
|
+
# @return [self]
|
|
34
|
+
def subscribe(&block)
|
|
35
|
+
@listeners << block
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param title [String]
|
|
40
|
+
# @param status [String] pending, in_progress, completed, or blocked
|
|
41
|
+
# @return [Entry]
|
|
42
|
+
# @raise [ArgumentError] on empty title or invalid status
|
|
43
|
+
def add(title, status: "pending")
|
|
44
|
+
raise ArgumentError, "title is required" if title.to_s.strip.empty?
|
|
45
|
+
|
|
46
|
+
entry = @mutex.synchronize do
|
|
47
|
+
e = Entry.new(id: "todo_#{@next_id}", title: title.to_s, status: validate_status(status || "pending"))
|
|
48
|
+
@next_id += 1
|
|
49
|
+
@entries << e
|
|
50
|
+
e
|
|
51
|
+
end
|
|
52
|
+
notify
|
|
53
|
+
entry
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param id [String] entry id from {Entry#id}
|
|
57
|
+
# @param status [String, nil]
|
|
58
|
+
# @param title [String, nil]
|
|
59
|
+
# @return [Entry] the updated entry
|
|
60
|
+
# @raise [ArgumentError] on unknown id or invalid status
|
|
61
|
+
def update(id, status: nil, title: nil)
|
|
62
|
+
entry = @mutex.synchronize do
|
|
63
|
+
index = @entries.index { |e| e.id == id }
|
|
64
|
+
raise ArgumentError, "no todo with id #{id.inspect}" unless index
|
|
65
|
+
|
|
66
|
+
@entries[index] = Entry.new(
|
|
67
|
+
id: id,
|
|
68
|
+
title: title.nil? ? @entries[index].title : title.to_s,
|
|
69
|
+
status: status.nil? ? @entries[index].status : validate_status(status)
|
|
70
|
+
)
|
|
71
|
+
@entries[index]
|
|
72
|
+
end
|
|
73
|
+
notify
|
|
74
|
+
entry
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [void]
|
|
78
|
+
def clear
|
|
79
|
+
@mutex.synchronize { @entries.clear }
|
|
80
|
+
notify
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Hash] serialized form for persistence (checkpoints)
|
|
85
|
+
def to_h
|
|
86
|
+
{ entries: all.map(&:to_h) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rebuild from a serialized snapshot (rollback, fork, load). Fires no
|
|
90
|
+
# events.
|
|
91
|
+
#
|
|
92
|
+
# @param data [Hash, nil]
|
|
93
|
+
# @return [void]
|
|
94
|
+
def restore(data)
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
raw = Array(data&.dig(:entries) || data&.dig("entries") || [])
|
|
97
|
+
@entries = raw.map do |e|
|
|
98
|
+
Entry.new(
|
|
99
|
+
id: (e[:id] || e["id"]).to_s,
|
|
100
|
+
title: (e[:title] || e["title"]).to_s,
|
|
101
|
+
status: (e[:status] || e["status"]).to_s
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
@next_id = @entries.size + 1
|
|
105
|
+
end
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [String] human-readable list
|
|
110
|
+
def to_s
|
|
111
|
+
entries = all
|
|
112
|
+
return "(no todos)" if entries.empty?
|
|
113
|
+
|
|
114
|
+
entries.map { |e| "[#{e.status}] #{e.title} (#{e.id})" }.join("\n")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def validate_status(status)
|
|
120
|
+
s = status.to_s
|
|
121
|
+
raise ArgumentError, "invalid status #{s.inspect}; valid: #{STATUSES.join(', ')}" unless STATUSES.include?(s)
|
|
122
|
+
|
|
123
|
+
s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def notify
|
|
127
|
+
snapshot = all
|
|
128
|
+
@listeners.each { |listener| listener.call(snapshot) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask/tools/tool"
|
|
4
|
+
require "ask/result"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Agent
|
|
8
|
+
# Tool that maintains the session's task list ({TodoList}). The model
|
|
9
|
+
# writes and updates todos as it works; every result returns the full
|
|
10
|
+
# list, so one call both mutates and shows state. A TodoUpdated event
|
|
11
|
+
# fires on every change for live rendering.
|
|
12
|
+
#
|
|
13
|
+
# Injected into the session by `Session.new(todos: true)`.
|
|
14
|
+
class TodoWrite < Ask::Tool
|
|
15
|
+
description "Maintain a task list for the current job. " \
|
|
16
|
+
"Use it to plan multi-step work and update statuses as steps finish. " \
|
|
17
|
+
"Actions: add (with title), update (with id and status or title), list, clear."
|
|
18
|
+
|
|
19
|
+
param :action, type: :string, desc: "add, update, list, or clear", required: true
|
|
20
|
+
param :title, type: :string, desc: "Task title (required for add)", required: false
|
|
21
|
+
param :id, type: :string, desc: "Task id (required for update)", required: false
|
|
22
|
+
param :status, type: :string, desc: "pending, in_progress, completed, or blocked", required: false
|
|
23
|
+
|
|
24
|
+
# @param todo_list [Ask::Agent::TodoList] the session's task list
|
|
25
|
+
def initialize(todo_list:)
|
|
26
|
+
@todo_list = todo_list
|
|
27
|
+
super()
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute(action:, title: nil, id: nil, status: nil)
|
|
31
|
+
case action
|
|
32
|
+
when "add"
|
|
33
|
+
@todo_list.add(title, status: status)
|
|
34
|
+
when "update"
|
|
35
|
+
@todo_list.update(id, status: status, title: title)
|
|
36
|
+
when "list"
|
|
37
|
+
# no-op — the result carries the full list
|
|
38
|
+
when "clear"
|
|
39
|
+
@todo_list.clear
|
|
40
|
+
else
|
|
41
|
+
return Ask::Result.error(message: "Unknown action #{action.inspect}; valid: add, update, list, clear")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Ask::Result.ok(data: format_list)
|
|
45
|
+
rescue ArgumentError => e
|
|
46
|
+
Ask::Result.error(message: e.message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def format_list
|
|
52
|
+
entries = @todo_list.all
|
|
53
|
+
return "(no todos)" if entries.empty?
|
|
54
|
+
|
|
55
|
+
entries.map { |e| "[#{e.status}] #{e.title} (#{e.id})" }.join("\n")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -35,10 +35,14 @@ module Ask
|
|
|
35
35
|
autoload :RateLimiter, "ask/agent/policies/rate_limiter"
|
|
36
36
|
autoload :AuditLog, "ask/agent/policies/audit_log"
|
|
37
37
|
autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
|
|
38
|
+
autoload :PermissionRules, "ask/agent/policies/permission_rules"
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
autoload :ToolCallRepair, "ask/agent/tool_call_repair"
|
|
41
42
|
autoload :CheckpointStore, "ask/agent/checkpoint_store"
|
|
43
|
+
autoload :TodoList, "ask/agent/todo_list"
|
|
44
|
+
autoload :TodoWrite, "ask/agent/todo_write"
|
|
45
|
+
autoload :ExitPlanMode, "ask/agent/exit_plan_mode"
|
|
42
46
|
|
|
43
47
|
module Middleware
|
|
44
48
|
autoload :Base, "ask/agent/middleware/base"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.32.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -175,6 +175,7 @@ files:
|
|
|
175
175
|
- lib/ask/agent/definition.rb
|
|
176
176
|
- lib/ask/agent/evaluator.rb
|
|
177
177
|
- lib/ask/agent/events.rb
|
|
178
|
+
- lib/ask/agent/exit_plan_mode.rb
|
|
178
179
|
- lib/ask/agent/hooks.rb
|
|
179
180
|
- lib/ask/agent/loop.rb
|
|
180
181
|
- lib/ask/agent/meta_agent.rb
|
|
@@ -189,6 +190,7 @@ files:
|
|
|
189
190
|
- lib/ask/agent/policies/approval_policy.rb
|
|
190
191
|
- lib/ask/agent/policies/audit_log.rb
|
|
191
192
|
- lib/ask/agent/policies/audit_log/active_record_writer.rb
|
|
193
|
+
- lib/ask/agent/policies/permission_rules.rb
|
|
192
194
|
- lib/ask/agent/policies/permissions.rb
|
|
193
195
|
- lib/ask/agent/policies/rate_limiter.rb
|
|
194
196
|
- lib/ask/agent/reflector.rb
|
|
@@ -204,6 +206,8 @@ files:
|
|
|
204
206
|
- lib/ask/agent/system_context.rb
|
|
205
207
|
- lib/ask/agent/telemetry.rb
|
|
206
208
|
- lib/ask/agent/test.rb
|
|
209
|
+
- lib/ask/agent/todo_list.rb
|
|
210
|
+
- lib/ask/agent/todo_write.rb
|
|
207
211
|
- lib/ask/agent/tool_abort_controller.rb
|
|
208
212
|
- lib/ask/agent/tool_call_repair.rb
|
|
209
213
|
- lib/ask/agent/tool_executor.rb
|