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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf67fcc30605c8d4752a97133a13a7fbe500c32a68c47640552bc30cf6dbea5b
4
- data.tar.gz: 638931a8c1c2c5ae0ca5535e9e78e9252910106af9134366a455523925b6d895
3
+ metadata.gz: ad75fe0e23adeabc49b068df1916a102d7122710dfe5a45471ba68b47864d75c
4
+ data.tar.gz: 4f7381343e7d63a4d2b4ffe9e70cf93c92ab941ccdf11dad52d6ff65bbe227cb
5
5
  SHA512:
6
- metadata.gz: 01ca01b502aa5789eef910f50626085bb6675e86dedd435b189b0e928d592ac27c48c145ff5c159580c2a9854528160c86074031dccd971b033b75b6f42bf3fa
7
- data.tar.gz: 6bf3ebc3428cf64c8a1ee7e958e45c349669e4eaba92a8bc5d90b522f0b9a47d4f3743570db235b8d9b4e526d3ea470eb1a81795acc6492fd67afda4b59d9b70
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
@@ -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, or
50
- # {action: :pending, action_id:, reason:} to queue for approval
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 = tool_auto_approvable?(tool_call.name)
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
@@ -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, **chat_options)
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
- checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?
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
- data[:messages].each do |msg|
337
- session.chat.add_message(
338
- role: msg[:role].to_sym,
339
- content: msg[:content],
340
- tool_call_id: msg[:tool_call_id]
341
- )
342
- end
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.30.1"
5
+ VERSION = "0.32.0"
6
6
  end
7
7
  end
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.30.1
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