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 +4 -4
- data/CHANGELOG.md +50 -0
- data/lib/ask/agent/events.rb +8 -0
- data/lib/ask/agent/exit_plan_mode.rb +47 -0
- data/lib/ask/agent/memory.rb +162 -0
- data/lib/ask/agent/memory_search.rb +32 -0
- data/lib/ask/agent/memory_write.rb +38 -0
- data/lib/ask/agent/session.rb +133 -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 +6 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90d637b9a007fef96f7b03e78ac61366ec3597bd789dfb3aaa36059687fb1632
|
|
4
|
+
data.tar.gz: 48e9b46cd59bdbefa5e93dc2925c42c13f8b9ab85b389a2e4bf2b15b2c7add1e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
|
@@ -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
|
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, 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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
data/lib/ask/agent/version.rb
CHANGED
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.
|
|
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
|