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 +4 -4
- data/CHANGELOG.md +29 -0
- data/lib/ask/agent/events.rb +8 -0
- data/lib/ask/agent/exit_plan_mode.rb +47 -0
- data/lib/ask/agent/session.rb +106 -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 +3 -0
- metadata +4 -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,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
|
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
|
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
|
|
@@ -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
|
data/lib/ask/agent/version.rb
CHANGED
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.
|
|
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
|