ask-agent 0.32.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 +21 -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 +28 -1
- 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: 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,24 @@
|
|
|
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
|
+
|
|
1
22
|
## [0.32.0] — 2026-08-06
|
|
2
23
|
|
|
3
24
|
### Added
|
|
@@ -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
|
@@ -24,7 +24,7 @@ module Ask
|
|
|
24
24
|
agent_dir: nil, evaluator: nil, audit_log: nil,
|
|
25
25
|
skills_disclosure: true, approval: nil,
|
|
26
26
|
tool_call_repair: nil, checkpoints: false,
|
|
27
|
-
todos: false, plan_mode: false, **chat_options)
|
|
27
|
+
todos: false, plan_mode: false, memory: nil, **chat_options)
|
|
28
28
|
@id = id || SecureRandom.uuid
|
|
29
29
|
@agent_dir = agent_dir
|
|
30
30
|
@max_turns = max_turns
|
|
@@ -54,6 +54,10 @@ module Ask
|
|
|
54
54
|
@todo_list = TodoList.new if @todos_enabled
|
|
55
55
|
@todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) }
|
|
56
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
|
+
|
|
57
61
|
# Plan mode — research phase gated to read-only tools until a human
|
|
58
62
|
# approves the model's plan (submitted via the exit_plan_mode tool).
|
|
59
63
|
@plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
|
|
@@ -137,6 +141,9 @@ module Ask
|
|
|
137
141
|
# @return [Ask::Agent::TodoList, nil] session task list (only when
|
|
138
142
|
# todos are enabled)
|
|
139
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
|
|
140
147
|
|
|
141
148
|
def run(message, tools: nil, reset: true)
|
|
142
149
|
raise "Session deleted" if @deleted
|
|
@@ -154,6 +161,9 @@ module Ask
|
|
|
154
161
|
|
|
155
162
|
active_tools = @tools
|
|
156
163
|
|
|
164
|
+
# Retrieve relevant memories from previous sessions into context.
|
|
165
|
+
inject_memories(message) if reset && @memory
|
|
166
|
+
|
|
157
167
|
if active_tools.empty? && !@_no_tools_instructed
|
|
158
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.")
|
|
159
169
|
@_no_tools_instructed = true
|
|
@@ -474,6 +484,19 @@ module Ask
|
|
|
474
484
|
|
|
475
485
|
# --- Plan mode ---
|
|
476
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
|
+
|
|
477
500
|
# @return [Boolean] whether the session is in plan mode (research
|
|
478
501
|
# phase; non-read-only tools are blocked until the plan is approved)
|
|
479
502
|
def plan_mode? = @plan_mode
|
|
@@ -724,6 +747,10 @@ module Ask
|
|
|
724
747
|
on_submit: ->(plan) { emit(Events::PlanProposed.new(plan: plan)) }
|
|
725
748
|
) unless resolved.any? { |t| t.name == "exit_plan_mode" }
|
|
726
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
|
|
727
754
|
resolved
|
|
728
755
|
end
|
|
729
756
|
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -43,6 +43,9 @@ module Ask
|
|
|
43
43
|
autoload :TodoList, "ask/agent/todo_list"
|
|
44
44
|
autoload :TodoWrite, "ask/agent/todo_write"
|
|
45
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"
|
|
46
49
|
|
|
47
50
|
module Middleware
|
|
48
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
|
|
@@ -178,6 +178,9 @@ files:
|
|
|
178
178
|
- lib/ask/agent/exit_plan_mode.rb
|
|
179
179
|
- lib/ask/agent/hooks.rb
|
|
180
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
|
|
181
184
|
- lib/ask/agent/meta_agent.rb
|
|
182
185
|
- lib/ask/agent/middleware/base.rb
|
|
183
186
|
- lib/ask/agent/middleware/default_settings.rb
|