ask-agent 0.31.0 → 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: e039a90d18224ab29b454dd2094a81628ddf80cc39938419038378182ecbe472
4
- data.tar.gz: 8d6d1a8005d2451469a998d36061adea945245beef774876b5b5bc1565f8fd5a
3
+ metadata.gz: ad75fe0e23adeabc49b068df1916a102d7122710dfe5a45471ba68b47864d75c
4
+ data.tar.gz: 4f7381343e7d63a4d2b4ffe9e70cf93c92ab941ccdf11dad52d6ff65bbe227cb
5
5
  SHA512:
6
- metadata.gz: 014b65f0247e65970d3d4fa7496744aa7d38f00f2684c53ff02f74ec5c547b6534c906b24dc6594387069e5034b1916e7f4e3a2986c6bf55e42412177ac7a678
7
- data.tar.gz: 72f571ba1ffafca8f9e9f181d51891ce9ea315e4ca5ebc15e1bf27557e8c05faf9ed6f3136a0a88a124f6833f096b5bb7d6cc9d7db9b32bc1dc0a27eb6cff1a9
6
+ metadata.gz: 86a1695d69a497b6ec0fd5b57c942084330a70b35b8bf5fd3340db03ce186be98350eec974c1aaec075e4ef30c8b79628d8c3028d3c64fdb288619f27ff58fda
7
+ data.tar.gz: b771e752c640319a5de1bbe63f9dec8079e62a9f8d371c8e82b90898e9af303528739c62c852f83cda58513296edd4ffe9372e090f189914149ab417d26bb254
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
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
+
1
30
  ## [0.31.0] — 2026-08-06
2
31
 
3
32
  ### Added
@@ -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
@@ -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
@@ -631,6 +715,15 @@ module Ask
631
715
  if skills_disclosure_enabled?
632
716
  resolved << Ask::Skills::LoadSkillTool.new(registry: @skills_registry) unless resolved.any? { |t| t.name == "load_skill" }
633
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
634
727
  resolved
635
728
  end
636
729
 
@@ -695,6 +788,7 @@ module Ask
695
788
  end
696
789
  target.instance_variable_set(:@messages, target.chat.messages.dup)
697
790
  target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
791
+ target.instance_variable_get(:@todo_list)&.restore(data[:todos])
698
792
  end
699
793
 
700
794
  # User-supplied tools only. Framework-injected tools (the built-in
@@ -717,6 +811,7 @@ module Ask
717
811
  created_at: Time.now.iso8601
718
812
  }
719
813
  },
814
+ todos: @todo_list&.to_h,
720
815
  metadata: {
721
816
  model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
722
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.31.0"
5
+ VERSION = "0.32.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -40,6 +40,9 @@ module Ask
40
40
 
41
41
  autoload :ToolCallRepair, "ask/agent/tool_call_repair"
42
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"
43
46
 
44
47
  module Middleware
45
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.31.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
@@ -205,6 +206,8 @@ files:
205
206
  - lib/ask/agent/system_context.rb
206
207
  - lib/ask/agent/telemetry.rb
207
208
  - lib/ask/agent/test.rb
209
+ - lib/ask/agent/todo_list.rb
210
+ - lib/ask/agent/todo_write.rb
208
211
  - lib/ask/agent/tool_abort_controller.rb
209
212
  - lib/ask/agent/tool_call_repair.rb
210
213
  - lib/ask/agent/tool_executor.rb