ask-agent 0.31.0 → 0.33.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: 90d637b9a007fef96f7b03e78ac61366ec3597bd789dfb3aaa36059687fb1632
4
+ data.tar.gz: 48e9b46cd59bdbefa5e93dc2925c42c13f8b9ab85b389a2e4bf2b15b2c7add1e
5
5
  SHA512:
6
- metadata.gz: 014b65f0247e65970d3d4fa7496744aa7d38f00f2684c53ff02f74ec5c547b6534c906b24dc6594387069e5034b1916e7f4e3a2986c6bf55e42412177ac7a678
7
- data.tar.gz: 72f571ba1ffafca8f9e9f181d51891ce9ea315e4ca5ebc15e1bf27557e8c05faf9ed6f3136a0a88a124f6833f096b5bb7d6cc9d7db9b32bc1dc0a27eb6cff1a9
6
+ metadata.gz: a1f76527ad518404c64c36c697db32fdebdb4b6381d86513e3aca4658486db238c2ab08a4df4df9ebdd6f0997fb9a5620c8d4d77bf69e9dbd70bb3235009d8a7
7
+ data.tar.gz: b1697c33f4ca2dad32a05e716c060ec14baaf848e9b39f918a43a8917682fe7baf76c80ea583ffa44f648fe14c778d4b39fbb1d4237adae6718b78d14c6ea10f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
1
+ ## [0.33.0] — 2026-08-06
2
+
3
+ ### Added
4
+
5
+ - **Durable memory — facts that outlive sessions.** `Ask::Agent::Memory`
6
+ stores namespaced entries on the same `Ask::State::Adapter` as sessions
7
+ and checkpoints (no new dependencies, no ask-rag mandate):
8
+ - Storage: one key per entry (`memory:<namespace>:<id>`) plus a JSON
9
+ index key for enumeration — pure KV, works with every backend
10
+ (SQLite/Redis/Postgres/MySQL/custom adapters) and with the in-process
11
+ Memory store.
12
+ - `Memory#write` (dedupes identical content), `#search` (keyword
13
+ substring match, ranked by matched terms, punctuation-stripped
14
+ queries), `#list`, `#delete`, `#count`. Namespaces isolate tenants and
15
+ agent roles.
16
+ - **Session integration**: `Session.new(memory: memory)` injects
17
+ `memory_write` (stamps the session id as provenance) and
18
+ `memory_search` tools, and **injects relevant memories as a system
19
+ message at run start** — session B starts knowing what session A
20
+ learned. Opt-in; sessions without `memory:` are unaffected.
21
+
22
+ ## [0.32.0] — 2026-08-06
23
+
24
+ ### Added
25
+
26
+ - **TodoWrite — the model maintains a live task list.** `Session.new(todos:
27
+ true)` injects a `todo_write` tool backed by a session-scoped
28
+ `Ask::Agent::TodoList`:
29
+ - Actions `add` / `update` / `list` / `clear` with `pending`,
30
+ `in_progress`, `completed`, `blocked` statuses; every result returns
31
+ the full list so one call both mutates and shows state.
32
+ - `Events::TodoUpdated` fires with the full list on every change — the
33
+ contract for live progress rendering (UI kit / app server).
34
+ - The list is part of the checkpoint snapshot: `rollback!` and `fork`
35
+ restore it, and `Session.load` re-enables todos automatically.
36
+ - **Plan mode — research first, execute after human approval.** `Session.new(
37
+ plan_mode: true)` (or `plan_mode: { read_only_tools: [...] }`) starts the
38
+ session in a research phase where non-read-only tools are blocked
39
+ (`:block` with a plan-mode reason; the gate runs before user hooks and
40
+ the approval policy). The model researches, then calls the injected
41
+ `exit_plan_mode` tool with its plan:
42
+ - The plan is submitted to a dedicated `Session#plan_queue` and the tool
43
+ returns a pending result — the agent hands back the interim reply,
44
+ exactly like the tool-approval flow.
45
+ - **Approve** → plan mode turns off, `Events::PlanApproved` fires, and a
46
+ follow-up turn executes the plan. **Reject** → the session stays in
47
+ plan mode, `Events::PlanRejected` fires, and the rejection feedback
48
+ reaches the conversation.
49
+ - Default read-only allowlist: `read`, `glob`, `grep`, `web_search`.
50
+
1
51
  ## [0.31.0] — 2026-08-06
2
52
 
3
53
  ### 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
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Ask
8
+ module Agent
9
+ # Durable, namespaced memory on any {Ask::State::Adapter} — the same
10
+ # storage layer as sessions and checkpoints.
11
+ #
12
+ # Entries are plain facts ("the deploy window is Tuesday", "the user
13
+ # prefers concise answers") that outlive a session: session A writes
14
+ # them, session B (same adapter + namespace) retrieves them via keyword
15
+ # search and has them injected into context. The abstraction is
16
+ # domain-agnostic — nothing here assumes a coding agent.
17
+ #
18
+ # Storage shape (pure KV, no list primitives — works with every backend
19
+ # including custom get/set/delete adapters):
20
+ # memory:<namespace>:<id> — one key per entry
21
+ # memory:<namespace>:index — JSON array of entry ids (write order)
22
+ #
23
+ # store = Ask::State::Providers::SQLite.new(path: "agent.db")
24
+ # memory = Ask::Agent::Memory.new(state: store, namespace: "user:42")
25
+ # memory.write("Deploy window is Tuesday")
26
+ # memory.search("when can we deploy?") # => [Entry]
27
+ #
28
+ # Namespaces isolate memory: a support agent's facts never leak into a
29
+ # finance agent's, and tenants share one backend safely.
30
+ class Memory
31
+ Entry = Data.define(:id, :content, :metadata, :created_at) do
32
+ def to_h = { id: id, content: content, metadata: metadata, created_at: created_at.iso8601 }
33
+ end
34
+
35
+ KEY_PREFIX = "memory:"
36
+ INDEX_SUFFIX = ":index"
37
+
38
+ # @param state [Ask::State::Adapter] backing store
39
+ # @param namespace [String] isolation scope (user id, project id, ...)
40
+ def initialize(state:, namespace:)
41
+ @state = state
42
+ @namespace = namespace.to_s
43
+ @mutex = Mutex.new
44
+ end
45
+
46
+ # @return [Ask::State::Adapter] the underlying adapter
47
+ attr_reader :state
48
+
49
+ # @return [String] the namespace this memory is scoped to
50
+ attr_reader :namespace
51
+
52
+ # Save a fact. Writing an identical content again is a no-op (returns
53
+ # the existing entry).
54
+ #
55
+ # @param content [String] the fact to remember
56
+ # @param metadata [Hash] optional provenance (session id, tags, ...)
57
+ # @return [Entry]
58
+ # @raise [ArgumentError] on empty content
59
+ def write(content, metadata: {})
60
+ content = content.to_s
61
+ raise ArgumentError, "content is required" if content.strip.empty?
62
+
63
+ @mutex.synchronize do
64
+ existing = entries.find { |e| e.content == content }
65
+ return existing if existing
66
+
67
+ entry = Entry.new(id: SecureRandom.uuid, content: content, metadata: metadata, created_at: Time.now)
68
+ @state.set(entry_key(entry.id), entry.to_h)
69
+ @state.set(index_key, (load_index + [entry.id]).to_json)
70
+ entry
71
+ end
72
+ end
73
+
74
+ # Keyword search over the namespace's entries: entries matching any
75
+ # query term (case-insensitive substring), ranked by matched-term
76
+ # count, newest first on ties.
77
+ #
78
+ # @param query [String]
79
+ # @param limit [Integer] max results
80
+ # @return [Array<Entry>]
81
+ def search(query, limit: 5)
82
+ terms = query.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").split(/\s+/).reject(&:empty?)
83
+ return [] if terms.empty?
84
+
85
+ scored = entries.filter_map do |entry|
86
+ text = entry.content.downcase
87
+ hits = terms.count { |term| text.include?(term) }
88
+ [hits, entry] if hits.positive?
89
+ end
90
+ scored.sort_by { |hits, entry| [-hits, entry.created_at] }.first(limit).map(&:last)
91
+ end
92
+
93
+ # @param limit [Integer]
94
+ # @return [Array<Entry>] entries, newest first
95
+ def list(limit: 50)
96
+ entries.last(limit).reverse
97
+ end
98
+
99
+ # Remove an entry by id.
100
+ #
101
+ # @param id [String]
102
+ # @return [void]
103
+ def delete(id)
104
+ @mutex.synchronize do
105
+ @state.delete(entry_key(id))
106
+ @state.set(index_key, (load_index - [id]).to_json)
107
+ end
108
+ nil
109
+ end
110
+
111
+ # @return [Integer] number of entries in this namespace
112
+ def count
113
+ entries.size
114
+ end
115
+
116
+ private
117
+
118
+ def entries
119
+ load_index.filter_map { |id| load_entry(id) }
120
+ end
121
+
122
+ def load_index
123
+ raw = @state.get(index_key)
124
+ raw ? JSON.parse(raw) : []
125
+ end
126
+
127
+ def load_entry(id)
128
+ data = @state.get(entry_key(id))
129
+ return nil unless data
130
+
131
+ data = symbolize(data)
132
+ Entry.new(
133
+ id: id,
134
+ content: data[:content].to_s,
135
+ metadata: data[:metadata] || {},
136
+ created_at: Time.parse(data[:created_at])
137
+ )
138
+ rescue ArgumentError, TypeError
139
+ nil
140
+ end
141
+
142
+ def entry_key(id)
143
+ "#{KEY_PREFIX}#{@namespace}:#{id}"
144
+ end
145
+
146
+ def index_key
147
+ "#{KEY_PREFIX}#{@namespace}#{INDEX_SUFFIX}"
148
+ end
149
+
150
+ def symbolize(obj)
151
+ case obj
152
+ when Hash
153
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
154
+ when Array
155
+ obj.map { |e| symbolize(e) }
156
+ else
157
+ obj
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,32 @@
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 searches the session's durable memory ({Memory}). Injected
9
+ # into the session by `Session.new(memory: memory)`.
10
+ class MemorySearch < Ask::Tool
11
+ description "Search durable memory for facts from previous sessions. " \
12
+ "Use this to recall user preferences, decisions, conventions, " \
13
+ "or resolved problems before answering."
14
+
15
+ param :query, type: :string, desc: "Search query", required: true
16
+ param :limit, type: :integer, desc: "Maximum number of results", required: false
17
+
18
+ # @param memory [Ask::Agent::Memory]
19
+ def initialize(memory:)
20
+ @memory = memory
21
+ super()
22
+ end
23
+
24
+ def execute(query:, limit: 5)
25
+ hits = @memory.search(query, limit: limit)
26
+ return Ask::Result.ok(data: "(no matching memories)") if hits.empty?
27
+
28
+ Ask::Result.ok(data: hits.map { |e| "- #{e.content}" }.join("\n"))
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
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 saves facts to the session's durable memory ({Memory}).
9
+ # Injected into the session by `Session.new(memory: memory)`.
10
+ class MemoryWrite < Ask::Tool
11
+ description "Save a fact to durable memory. " \
12
+ "Use this for information worth remembering across sessions: " \
13
+ "user preferences, decisions, conventions, resolved problems. " \
14
+ "The fact will be available to future sessions with the same namespace."
15
+
16
+ param :content, type: :string, desc: "The fact to remember", required: true
17
+
18
+ # @param memory [Ask::Agent::Memory]
19
+ # @param session_id [String] stamped into the entry metadata as
20
+ # provenance
21
+ def initialize(memory:, session_id:)
22
+ @memory = memory
23
+ @session_id = session_id
24
+ super()
25
+ end
26
+
27
+ def execute(content:)
28
+ entry = @memory.write(
29
+ content,
30
+ metadata: { session_id: @session_id, written_at: Time.now.iso8601 }
31
+ )
32
+ Ask::Result.ok(data: "Saved to memory (#{entry.id}): #{entry.content}")
33
+ rescue ArgumentError => e
34
+ Ask::Result.error(message: e.message)
35
+ end
36
+ end
37
+ end
38
+ 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, memory: nil, **chat_options)
27
28
  @id = id || SecureRandom.uuid
28
29
  @agent_dir = agent_dir
29
30
  @max_turns = max_turns
@@ -47,6 +48,29 @@ 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
+ # Durable memory (memory_write / memory_search tools). An instance
58
+ # with its own namespace and state adapter; nil disables memory.
59
+ @memory = memory
60
+
61
+ # Plan mode — research phase gated to read-only tools until a human
62
+ # approves the model's plan (submitted via the exit_plan_mode tool).
63
+ @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
64
+ @plan_mode_read_only_tools = if plan_mode.is_a?(Hash) && plan_mode[:read_only_tools]
65
+ Array(plan_mode[:read_only_tools]).map(&:to_s)
66
+ else
67
+ %w[read glob grep web_search]
68
+ end
69
+ @plan_queue = ApprovalQueue.new(
70
+ on_approve: ->(action) { approve_plan(action) },
71
+ on_reject: ->(action) { reject_plan(action) }
72
+ ) if @plan_mode
73
+
50
74
  @tools = resolve_tools(tools)
51
75
  @chat = build_chat(model, system_prompt, @tools, **chat_options)
52
76
  @loop = Loop.new(max_turns: max_turns)
@@ -57,6 +81,15 @@ module Ask
57
81
  @approval_queue = build_approval(approval)
58
82
  @tool_call_repair = tool_call_repair
59
83
 
84
+ # Plan gate runs before user hooks and the approval policy: in plan
85
+ # mode, non-read-only tools are blocked outright (never queued).
86
+ if @plan_mode
87
+ @hooks = Hooks.new(
88
+ before_tool: [method(:plan_mode_gate)] + Array(@hooks.instance_variable_get(:@before_tool)),
89
+ after_tool: @hooks.instance_variable_get(:@after_tool)
90
+ )
91
+ end
92
+
60
93
  @system_context = build_system_context(system_prompt)
61
94
  apply_system_context
62
95
 
@@ -102,6 +135,15 @@ module Ask
102
135
  #
103
136
  # @return [Ask::Agent::ApprovalQueue, nil]
104
137
  attr_reader :approval_queue
138
+ # @return [Ask::Agent::ApprovalQueue, nil] queue carrying plan
139
+ # approvals (only when plan mode is enabled)
140
+ attr_reader :plan_queue
141
+ # @return [Ask::Agent::TodoList, nil] session task list (only when
142
+ # todos are enabled)
143
+ attr_reader :todo_list
144
+ # @return [Ask::Agent::Memory, nil] durable memory (only when passed
145
+ # via the +memory:+ option)
146
+ attr_reader :memory
105
147
 
106
148
  def run(message, tools: nil, reset: true)
107
149
  raise "Session deleted" if @deleted
@@ -119,6 +161,9 @@ module Ask
119
161
 
120
162
  active_tools = @tools
121
163
 
164
+ # Retrieve relevant memories from previous sessions into context.
165
+ inject_memories(message) if reset && @memory
166
+
122
167
  if active_tools.empty? && !@_no_tools_instructed
123
168
  @chat.add_message(role: :system, content: "You have no tools available. Do not claim you can look up information or use tools of any kind. Just respond based on your existing knowledge.")
124
169
  @_no_tools_instructed = true
@@ -329,17 +374,20 @@ module Ask
329
374
  end,
330
375
  state: adapter,
331
376
  # Checkpointing is restored automatically when the session has
332
- # checkpoints in the store.
333
- checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?
377
+ # checkpoints in the store; todos likewise when the snapshot has
378
+ # a task list.
379
+ checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?,
380
+ todos: !data[:todos].nil?
334
381
  )
335
382
 
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
383
+ data[:messages].each do |msg|
384
+ session.chat.add_message(
385
+ role: msg[:role].to_sym,
386
+ content: msg[:content],
387
+ tool_call_id: msg[:tool_call_id]
388
+ )
389
+ end
390
+ session.instance_variable_get(:@todo_list)&.restore(data[:todos])
343
391
 
344
392
  session.instance_variable_set(:@messages, session.chat.messages.dup)
345
393
  session
@@ -425,13 +473,72 @@ module Ask
425
473
  model: data[:metadata][:model],
426
474
  tools: @tools,
427
475
  state: @state,
428
- checkpoints: true
476
+ checkpoints: true,
477
+ todos: @todos_enabled,
478
+ plan_mode: @plan_mode
429
479
  )
430
480
  restore_into(forked, data)
431
481
  emit(Events::SessionForked.new(session_id: @id, forked_id: forked_id, seq: seq))
432
482
  forked
433
483
  end
434
484
 
485
+ # --- Plan mode ---
486
+
487
+ # Retrieve memories relevant to the incoming message and inject them
488
+ # as a system message, so a new session starts with what earlier
489
+ # sessions learned.
490
+ def inject_memories(message)
491
+ hits = @memory.search(message.to_s, limit: 5)
492
+ return if hits.empty?
493
+
494
+ @chat.add_message(
495
+ role: :system,
496
+ content: "Relevant memories from previous sessions:\n" + hits.map { |e| "- #{e.content}" }.join("\n")
497
+ )
498
+ end
499
+
500
+ # @return [Boolean] whether the session is in plan mode (research
501
+ # phase; non-read-only tools are blocked until the plan is approved)
502
+ def plan_mode? = @plan_mode
503
+
504
+ # Before-tool gate active while in plan mode: only read-only tools
505
+ # (and exit_plan_mode itself) run until a human approves the plan.
506
+ def plan_mode_gate(tool_call, _context)
507
+ return { action: :proceed } unless @plan_mode
508
+ return { action: :proceed } if @plan_mode_read_only_tools.include?(tool_call.name) || tool_call.name == "exit_plan_mode"
509
+
510
+ { action: :block, reason: "Plan mode: only read-only tools until the plan is approved" }
511
+ end
512
+
513
+ def approve_plan(action)
514
+ @plan_mode = false
515
+ plan = action.args[:plan] || action.args["plan"] || ""
516
+ emit(Events::PlanApproved.new(plan: plan))
517
+ complete_pending_tool(
518
+ tool_call_id: action.tool_call_id,
519
+ result: {
520
+ tool_name: "exit_plan_mode",
521
+ message: "Plan approved — execute it now.",
522
+ status: "success",
523
+ is_error: false
524
+ }
525
+ )
526
+ end
527
+
528
+ def reject_plan(action)
529
+ plan = action.args[:plan] || action.args["plan"] || ""
530
+ emit(Events::PlanRejected.new(plan: plan))
531
+ complete_pending_tool(
532
+ tool_call_id: action.tool_call_id,
533
+ result: {
534
+ tool_name: "exit_plan_mode",
535
+ message: "Plan rejected by the user — revise your plan and resubmit.",
536
+ status: "rejected",
537
+ is_error: false
538
+ }
539
+ )
540
+ end
541
+
435
542
  # --- Async (pending) tools ---
436
543
 
437
544
  # Registers a pending tool call (called by the loop when a tool
@@ -631,6 +738,19 @@ module Ask
631
738
  if skills_disclosure_enabled?
632
739
  resolved << Ask::Skills::LoadSkillTool.new(registry: @skills_registry) unless resolved.any? { |t| t.name == "load_skill" }
633
740
  end
741
+ if @todo_list
742
+ resolved << TodoWrite.new(todo_list: @todo_list) unless resolved.any? { |t| t.name == "todo_write" }
743
+ end
744
+ if @plan_mode
745
+ resolved << ExitPlanMode.new(
746
+ plan_queue: @plan_queue,
747
+ on_submit: ->(plan) { emit(Events::PlanProposed.new(plan: plan)) }
748
+ ) unless resolved.any? { |t| t.name == "exit_plan_mode" }
749
+ end
750
+ if @memory
751
+ resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
752
+ resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
753
+ end
634
754
  resolved
635
755
  end
636
756
 
@@ -695,6 +815,7 @@ module Ask
695
815
  end
696
816
  target.instance_variable_set(:@messages, target.chat.messages.dup)
697
817
  target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
818
+ target.instance_variable_get(:@todo_list)&.restore(data[:todos])
698
819
  end
699
820
 
700
821
  # User-supplied tools only. Framework-injected tools (the built-in
@@ -717,6 +838,7 @@ module Ask
717
838
  created_at: Time.now.iso8601
718
839
  }
719
840
  },
841
+ todos: @todo_list&.to_h,
720
842
  metadata: {
721
843
  model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
722
844
  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.33.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -40,6 +40,12 @@ 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"
46
+ autoload :Memory, "ask/agent/memory"
47
+ autoload :MemoryWrite, "ask/agent/memory_write"
48
+ autoload :MemorySearch, "ask/agent/memory_search"
43
49
 
44
50
  module Middleware
45
51
  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.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -175,8 +175,12 @@ 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
181
+ - lib/ask/agent/memory.rb
182
+ - lib/ask/agent/memory_search.rb
183
+ - lib/ask/agent/memory_write.rb
180
184
  - lib/ask/agent/meta_agent.rb
181
185
  - lib/ask/agent/middleware/base.rb
182
186
  - lib/ask/agent/middleware/default_settings.rb
@@ -205,6 +209,8 @@ files:
205
209
  - lib/ask/agent/system_context.rb
206
210
  - lib/ask/agent/telemetry.rb
207
211
  - lib/ask/agent/test.rb
212
+ - lib/ask/agent/todo_list.rb
213
+ - lib/ask/agent/todo_write.rb
208
214
  - lib/ask/agent/tool_abort_controller.rb
209
215
  - lib/ask/agent/tool_call_repair.rb
210
216
  - lib/ask/agent/tool_executor.rb