ask-agent 0.32.0 → 0.35.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 +53 -0
- data/lib/ask/agent/chat.rb +7 -3
- data/lib/ask/agent/memory.rb +176 -0
- data/lib/ask/agent/memory_extractor.rb +118 -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 +49 -1
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +13 -3
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2040f37b4e9b023374eb6169abd1f1c28fdda335a8032254a15b0d813a7cd0c9
|
|
4
|
+
data.tar.gz: 7212cbd265d8b054423cfad5ed76717eb36f1ff980c2a394cba9b0b4c256c3e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cb0c3e65bd08e9c45f93bdc378149810d7c31594f63cf69799e128b9f32496f3e71ca521cbe43d6729a9d39210bce0469a7eb6686336a45f2677fc473053cad
|
|
7
|
+
data.tar.gz: 2b6d47cfb37af4c3a2d850a551621af6d44542dd88f9483a695cfcd83b58f2bb893a7403ac91b3a01f3659936ad8f4df5c8d7cc8eb7656c22e6587db0bba0081
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
+
## [0.35.0] — 2026-08-07
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Memory learning — automatic extraction (v2 of durable memory).**
|
|
6
|
+
`Session.new(memory: memory, memory_learning: true)` extracts durable
|
|
7
|
+
facts from the transcript when the session ends — the model no longer has
|
|
8
|
+
to remember to call `memory_write`:
|
|
9
|
+
- `Ask::Agent::MemoryExtractor` reads the memory-relevant messages (user
|
|
10
|
+
+ assistant, capped, oldest dropped), sends them to the model with a
|
|
11
|
+
configurable structured-output prompt, and writes the returned facts
|
|
12
|
+
into the store — deduped (exact + near-duplicate via search), stamped
|
|
13
|
+
with provenance (`extracted: true`, source session id), and capped
|
|
14
|
+
(`max_candidates:`, default 10).
|
|
15
|
+
- Extraction is best-effort: unparseable responses and failed calls
|
|
16
|
+
yield an empty result and never break the session.
|
|
17
|
+
- `Memory.new(max_entries:)` prunes the oldest entries once a namespace
|
|
18
|
+
exceeds the cap — bounded memory, not a growing dump.
|
|
19
|
+
- Requires `memory:`; `memory_learning:` without it raises.
|
|
20
|
+
|
|
21
|
+
## [0.34.1] — 2026-08-07
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`account_id` session option.** `Ask::Agent::Chat.new(..., account_id:)` merges the value into the provider config (e.g. `ChatGPT-Account-Id` for the OpenAI Codex provider).
|
|
26
|
+
|
|
27
|
+
## [0.34.0] — 2026-08-07
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Runtime model/provider/key overrides.** `Ask::Agent.new(name, model:, provider:, api_key:, api_base:)` and `Ask::Agent::Chat.new(..., api_key:, api_base:)` — caller-supplied options win over the definition's config, and an explicit `api_key`/`api_base` is merged into the provider config ahead of Ask::Auth resolution. This is the BYOK / per-user credential injection seam (a session can be built against a specific provider and key without touching global configuration).
|
|
32
|
+
|
|
33
|
+
## [0.33.0] — 2026-08-06
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Durable memory — facts that outlive sessions.** `Ask::Agent::Memory`
|
|
38
|
+
stores namespaced entries on the same `Ask::State::Adapter` as sessions
|
|
39
|
+
and checkpoints (no new dependencies, no ask-rag mandate):
|
|
40
|
+
- Storage: one key per entry (`memory:<namespace>:<id>`) plus a JSON
|
|
41
|
+
index key for enumeration — pure KV, works with every backend
|
|
42
|
+
(SQLite/Redis/Postgres/MySQL/custom adapters) and with the in-process
|
|
43
|
+
Memory store.
|
|
44
|
+
- `Memory#write` (dedupes identical content), `#search` (keyword
|
|
45
|
+
substring match, ranked by matched terms, punctuation-stripped
|
|
46
|
+
queries), `#list`, `#delete`, `#count`. Namespaces isolate tenants and
|
|
47
|
+
agent roles.
|
|
48
|
+
- **Session integration**: `Session.new(memory: memory)` injects
|
|
49
|
+
`memory_write` (stamps the session id as provenance) and
|
|
50
|
+
`memory_search` tools, and **injects relevant memories as a system
|
|
51
|
+
message at run start** — session B starts knowing what session A
|
|
52
|
+
learned. Opt-in; sessions without `memory:` are unaffected.
|
|
53
|
+
|
|
1
54
|
## [0.32.0] — 2026-08-06
|
|
2
55
|
|
|
3
56
|
### Added
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Ask
|
|
|
33
33
|
|
|
34
34
|
attr_writer :test_provider
|
|
35
35
|
|
|
36
|
-
def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, prompt_caching: nil, **)
|
|
36
|
+
def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, prompt_caching: nil, api_key: nil, api_base: nil, account_id: nil, **)
|
|
37
37
|
@model_id = model.respond_to?(:id) ? model.id : model.to_s
|
|
38
38
|
@model_info = Ask::ModelCatalog.find(@model_id)
|
|
39
39
|
@tools = tools
|
|
@@ -41,6 +41,9 @@ module Ask
|
|
|
41
41
|
@schema = schema
|
|
42
42
|
@messages = []
|
|
43
43
|
@provider_override = provider
|
|
44
|
+
@api_key = api_key
|
|
45
|
+
@api_base = api_base
|
|
46
|
+
@account_id = account_id
|
|
44
47
|
@provider = nil
|
|
45
48
|
|
|
46
49
|
# Read configured middleware, transforms, and caching from global config
|
|
@@ -130,12 +133,13 @@ module Ask
|
|
|
130
133
|
cred_names << [base_s.to_sym, :api_key]
|
|
131
134
|
end
|
|
132
135
|
|
|
133
|
-
key = Ask::Auth.resolve(*cred_names) rescue nil
|
|
136
|
+
key = @api_key || (Ask::Auth.resolve(*cred_names) rescue nil)
|
|
134
137
|
|
|
135
|
-
base_url = Ask::Auth.resolve(:"#{slug}_api_base") rescue nil
|
|
138
|
+
base_url = @api_base || (Ask::Auth.resolve(:"#{slug}_api_base") rescue nil)
|
|
136
139
|
config = { api_key: key }
|
|
137
140
|
config[:"#{slug}_api_key"] = key
|
|
138
141
|
config[:"#{slug}_api_base"] = base_url if base_url
|
|
142
|
+
config[:account_id] = @account_id if @account_id
|
|
139
143
|
Ask::LLM::Config.new(config)
|
|
140
144
|
end
|
|
141
145
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Ask
|
|
9
|
+
module Agent
|
|
10
|
+
# Durable, namespaced memory on any {Ask::State::Adapter} — the same
|
|
11
|
+
# storage layer as sessions and checkpoints.
|
|
12
|
+
#
|
|
13
|
+
# Entries are plain facts ("the deploy window is Tuesday", "the user
|
|
14
|
+
# prefers concise answers") that outlive a session: session A writes
|
|
15
|
+
# them, session B (same adapter + namespace) retrieves them via keyword
|
|
16
|
+
# search and has them injected into context. The abstraction is
|
|
17
|
+
# domain-agnostic — nothing here assumes a coding agent.
|
|
18
|
+
#
|
|
19
|
+
# Storage shape (pure KV, no list primitives — works with every backend
|
|
20
|
+
# including custom get/set/delete adapters):
|
|
21
|
+
# memory:<namespace>:<id> — one key per entry
|
|
22
|
+
# memory:<namespace>:index — JSON array of entry ids (write order)
|
|
23
|
+
#
|
|
24
|
+
# store = Ask::State::Providers::SQLite.new(path: "agent.db")
|
|
25
|
+
# memory = Ask::Agent::Memory.new(state: store, namespace: "user:42")
|
|
26
|
+
# memory.write("Deploy window is Tuesday")
|
|
27
|
+
# memory.search("when can we deploy?") # => [Entry]
|
|
28
|
+
#
|
|
29
|
+
# Namespaces isolate memory: a support agent's facts never leak into a
|
|
30
|
+
# finance agent's, and tenants share one backend safely.
|
|
31
|
+
class Memory
|
|
32
|
+
Entry = Data.define(:id, :content, :metadata, :created_at) do
|
|
33
|
+
def to_h = { id: id, content: content, metadata: metadata, created_at: created_at.iso8601 }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
KEY_PREFIX = "memory:"
|
|
37
|
+
INDEX_SUFFIX = ":index"
|
|
38
|
+
|
|
39
|
+
# @param state [Ask::State::Adapter] backing store
|
|
40
|
+
# @param namespace [String] isolation scope (user id, project id, ...)
|
|
41
|
+
# @param max_entries [Integer, nil] when set, the oldest entries are
|
|
42
|
+
# pruned once the namespace exceeds this many entries
|
|
43
|
+
def initialize(state:, namespace:, max_entries: nil)
|
|
44
|
+
@state = state
|
|
45
|
+
@namespace = namespace.to_s
|
|
46
|
+
@max_entries = max_entries
|
|
47
|
+
@mutex = Monitor.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Ask::State::Adapter] the underlying adapter
|
|
51
|
+
attr_reader :state
|
|
52
|
+
|
|
53
|
+
# @return [String] the namespace this memory is scoped to
|
|
54
|
+
attr_reader :namespace
|
|
55
|
+
|
|
56
|
+
# Save a fact. Writing an identical content again is a no-op (returns
|
|
57
|
+
# the existing entry).
|
|
58
|
+
#
|
|
59
|
+
# @param content [String] the fact to remember
|
|
60
|
+
# @param metadata [Hash] optional provenance (session id, tags, ...)
|
|
61
|
+
# @return [Entry]
|
|
62
|
+
# @raise [ArgumentError] on empty content
|
|
63
|
+
def write(content, metadata: {})
|
|
64
|
+
content = content.to_s
|
|
65
|
+
raise ArgumentError, "content is required" if content.strip.empty?
|
|
66
|
+
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
existing = entries.find { |e| e.content == content }
|
|
69
|
+
return existing if existing
|
|
70
|
+
|
|
71
|
+
entry = Entry.new(id: SecureRandom.uuid, content: content, metadata: metadata, created_at: Time.now)
|
|
72
|
+
@state.set(entry_key(entry.id), entry.to_h)
|
|
73
|
+
@state.set(index_key, (load_index + [entry.id]).to_json)
|
|
74
|
+
prune_oldest if @max_entries
|
|
75
|
+
entry
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Keyword search over the namespace's entries: entries matching any
|
|
80
|
+
# query term (case-insensitive substring), ranked by matched-term
|
|
81
|
+
# count, newest first on ties.
|
|
82
|
+
#
|
|
83
|
+
# @param query [String]
|
|
84
|
+
# @param limit [Integer] max results
|
|
85
|
+
# @return [Array<Entry>]
|
|
86
|
+
def search(query, limit: 5)
|
|
87
|
+
terms = query.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").split(/\s+/).reject(&:empty?)
|
|
88
|
+
return [] if terms.empty?
|
|
89
|
+
|
|
90
|
+
scored = entries.filter_map do |entry|
|
|
91
|
+
text = entry.content.downcase
|
|
92
|
+
hits = terms.count { |term| text.include?(term) }
|
|
93
|
+
[hits, entry] if hits.positive?
|
|
94
|
+
end
|
|
95
|
+
scored.sort_by { |hits, entry| [-hits, entry.created_at] }.first(limit).map(&:last)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param limit [Integer]
|
|
99
|
+
# @return [Array<Entry>] entries, newest first
|
|
100
|
+
def list(limit: 50)
|
|
101
|
+
entries.last(limit).reverse
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Remove an entry by id.
|
|
105
|
+
#
|
|
106
|
+
# @param id [String]
|
|
107
|
+
# @return [void]
|
|
108
|
+
def delete(id)
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@state.delete(entry_key(id))
|
|
111
|
+
@state.set(index_key, (load_index - [id]).to_json)
|
|
112
|
+
end
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Integer] number of entries in this namespace
|
|
117
|
+
def count
|
|
118
|
+
entries.size
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Drop the oldest entries beyond the max_entries cap (called under the
|
|
124
|
+
# write mutex; delete re-enters it, which is safe).
|
|
125
|
+
def prune_oldest
|
|
126
|
+
current = entries
|
|
127
|
+
return if current.size <= @max_entries
|
|
128
|
+
|
|
129
|
+
current.first(current.size - @max_entries).each { |entry| delete(entry.id) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def entries
|
|
133
|
+
load_index.filter_map { |id| load_entry(id) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_index
|
|
137
|
+
raw = @state.get(index_key)
|
|
138
|
+
raw ? JSON.parse(raw) : []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_entry(id)
|
|
142
|
+
data = @state.get(entry_key(id))
|
|
143
|
+
return nil unless data
|
|
144
|
+
|
|
145
|
+
data = symbolize(data)
|
|
146
|
+
Entry.new(
|
|
147
|
+
id: id,
|
|
148
|
+
content: data[:content].to_s,
|
|
149
|
+
metadata: data[:metadata] || {},
|
|
150
|
+
created_at: Time.parse(data[:created_at])
|
|
151
|
+
)
|
|
152
|
+
rescue ArgumentError, TypeError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def entry_key(id)
|
|
157
|
+
"#{KEY_PREFIX}#{@namespace}:#{id}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def index_key
|
|
161
|
+
"#{KEY_PREFIX}#{@namespace}#{INDEX_SUFFIX}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def symbolize(obj)
|
|
165
|
+
case obj
|
|
166
|
+
when Hash
|
|
167
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
|
|
168
|
+
when Array
|
|
169
|
+
obj.map { |e| symbolize(e) }
|
|
170
|
+
else
|
|
171
|
+
obj
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
# Extracts durable facts from a finished session's transcript and writes
|
|
8
|
+
# them to the session's {Memory} — the "learning" half of durable memory
|
|
9
|
+
# (codex two-phase pattern, phase 1).
|
|
10
|
+
#
|
|
11
|
+
# One LLM call with a structured-output prompt: the model reads the
|
|
12
|
+
# memory-relevant messages and returns a JSON list of durable facts.
|
|
13
|
+
# Candidates are deduped against the store (exact + near-duplicate via
|
|
14
|
+
# search), written with provenance (extracted: true, source session id),
|
|
15
|
+
# and capped at +max_candidates+.
|
|
16
|
+
#
|
|
17
|
+
# Extraction never raises: a failed call or unparseable response yields
|
|
18
|
+
# an empty result hash, never a broken session.
|
|
19
|
+
#
|
|
20
|
+
# Session wiring: `Session.new(memory: memory, memory_learning: true)`.
|
|
21
|
+
class MemoryExtractor
|
|
22
|
+
DEFAULT_SYSTEM_PROMPT = <<~PROMPT.strip
|
|
23
|
+
You are a memory curator. Read the conversation transcript and extract
|
|
24
|
+
durable facts worth remembering across sessions: user preferences,
|
|
25
|
+
decisions, conventions, resolved problems, and standing instructions.
|
|
26
|
+
Skip ephemeral details, session-specific chatter, secrets, and facts
|
|
27
|
+
already obvious from the conversation itself.
|
|
28
|
+
Respond with JSON only: {"facts": ["fact one", "fact two", ...]}.
|
|
29
|
+
Return an empty list if nothing is worth remembering.
|
|
30
|
+
PROMPT
|
|
31
|
+
|
|
32
|
+
# @param model [String] model to extract with
|
|
33
|
+
# @param memory [Ask::Agent::Memory] store to write into
|
|
34
|
+
# @param chat [Ask::Agent::Chat, nil] chat to use (built from +model+
|
|
35
|
+
# when nil; inject a stub in tests)
|
|
36
|
+
# @param system_prompt [String] domain-specific extraction instructions
|
|
37
|
+
# @param max_candidates [Integer] cap on facts written per extraction
|
|
38
|
+
# @param max_transcript_messages [Integer] cap on transcript messages
|
|
39
|
+
# sent to the model (oldest dropped first)
|
|
40
|
+
def initialize(model:, memory:, chat: nil, system_prompt: DEFAULT_SYSTEM_PROMPT,
|
|
41
|
+
max_candidates: 10, max_transcript_messages: 60)
|
|
42
|
+
@model = model
|
|
43
|
+
@memory = memory
|
|
44
|
+
@chat = chat
|
|
45
|
+
@system_prompt = system_prompt
|
|
46
|
+
@max_candidates = max_candidates
|
|
47
|
+
@max_transcript_messages = max_transcript_messages
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param transcript [Array<Ask::Message>] the finished session's messages
|
|
51
|
+
# @param session_id [String, nil] stamped into extracted entries as
|
|
52
|
+
# provenance
|
|
53
|
+
# @return [Hash] {extracted: [String], skipped: [String], error: [String, nil]}
|
|
54
|
+
def extract(transcript:, session_id: nil)
|
|
55
|
+
relevant = memory_relevant_messages(transcript)
|
|
56
|
+
return { extracted: [], skipped: [], error: nil } if relevant.empty?
|
|
57
|
+
|
|
58
|
+
facts = request_facts(relevant)
|
|
59
|
+
return { extracted: [], skipped: [], error: "no facts returned" } if facts.empty?
|
|
60
|
+
|
|
61
|
+
extracted = []
|
|
62
|
+
skipped = []
|
|
63
|
+
facts.first(@max_candidates).each do |fact|
|
|
64
|
+
if duplicate?(fact)
|
|
65
|
+
skipped << fact
|
|
66
|
+
else
|
|
67
|
+
@memory.write(
|
|
68
|
+
fact,
|
|
69
|
+
metadata: { extracted: true, session_id: session_id, extracted_at: Time.now.iso8601 }
|
|
70
|
+
)
|
|
71
|
+
extracted << fact
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
{ extracted: extracted, skipped: skipped, error: nil }
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
{ extracted: [], skipped: [], error: e.message }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# User and assistant messages with content, oldest dropped first beyond
|
|
82
|
+
# the cap. Tool and system messages never reach the model.
|
|
83
|
+
def memory_relevant_messages(transcript)
|
|
84
|
+
transcript
|
|
85
|
+
.select { |m| %i[user assistant].include?(m.role.to_sym) && m.content.to_s.strip.length.positive? }
|
|
86
|
+
.last(@max_transcript_messages)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def request_facts(messages)
|
|
90
|
+
body = messages.map { |m| "#{m.role}: #{m.content}" }.join("\n")
|
|
91
|
+
response = chat.ask("#{@system_prompt}\n\nTranscript:\n#{body}")
|
|
92
|
+
parse_facts(response.content.to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def chat
|
|
96
|
+
@chat ||= Chat.new(model: @model, tools: [])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_facts(content)
|
|
100
|
+
parsed = JSON.parse(content)
|
|
101
|
+
facts = parsed.is_a?(Hash) ? (parsed["facts"] || parsed[:facts]) : parsed
|
|
102
|
+
Array(facts).map(&:to_s).map(&:strip).reject(&:empty?)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
# Fallback: the first JSON array in the response.
|
|
105
|
+
match = content.match(/\[.*\]/m)
|
|
106
|
+
match ? JSON.parse(match[0]).map(&:to_s).map(&:strip).reject(&:empty?) : []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def duplicate?(fact)
|
|
110
|
+
@memory.search(fact, limit: 1).any? { |entry| similar?(entry.content, fact) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def similar?(a, b)
|
|
114
|
+
a == b || a.include?(b) || b.include?(a)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
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,8 @@ 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,
|
|
27
|
+
todos: false, plan_mode: false, memory: nil,
|
|
28
|
+
memory_learning: false, **chat_options)
|
|
28
29
|
@id = id || SecureRandom.uuid
|
|
29
30
|
@agent_dir = agent_dir
|
|
30
31
|
@max_turns = max_turns
|
|
@@ -54,6 +55,16 @@ module Ask
|
|
|
54
55
|
@todo_list = TodoList.new if @todos_enabled
|
|
55
56
|
@todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) }
|
|
56
57
|
|
|
58
|
+
# Durable memory (memory_write / memory_search tools). An instance
|
|
59
|
+
# with its own namespace and state adapter; nil disables memory.
|
|
60
|
+
@memory = memory
|
|
61
|
+
# Learning: extract durable facts from the transcript when the
|
|
62
|
+
# session ends (requires memory).
|
|
63
|
+
if memory_learning && !@memory
|
|
64
|
+
raise ArgumentError, "memory_learning: requires a memory: instance"
|
|
65
|
+
end
|
|
66
|
+
@memory_learning = !!memory_learning
|
|
67
|
+
|
|
57
68
|
# Plan mode — research phase gated to read-only tools until a human
|
|
58
69
|
# approves the model's plan (submitted via the exit_plan_mode tool).
|
|
59
70
|
@plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
|
|
@@ -137,6 +148,9 @@ module Ask
|
|
|
137
148
|
# @return [Ask::Agent::TodoList, nil] session task list (only when
|
|
138
149
|
# todos are enabled)
|
|
139
150
|
attr_reader :todo_list
|
|
151
|
+
# @return [Ask::Agent::Memory, nil] durable memory (only when passed
|
|
152
|
+
# via the +memory:+ option)
|
|
153
|
+
attr_reader :memory
|
|
140
154
|
|
|
141
155
|
def run(message, tools: nil, reset: true)
|
|
142
156
|
raise "Session deleted" if @deleted
|
|
@@ -154,6 +168,9 @@ module Ask
|
|
|
154
168
|
|
|
155
169
|
active_tools = @tools
|
|
156
170
|
|
|
171
|
+
# Retrieve relevant memories from previous sessions into context.
|
|
172
|
+
inject_memories(message) if reset && @memory
|
|
173
|
+
|
|
157
174
|
if active_tools.empty? && !@_no_tools_instructed
|
|
158
175
|
@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
176
|
@_no_tools_instructed = true
|
|
@@ -199,6 +216,10 @@ module Ask
|
|
|
199
216
|
@running = false
|
|
200
217
|
Ask::Agent.current_session = nil if Ask::Agent.current_session.equal?(self)
|
|
201
218
|
persist! if @state
|
|
219
|
+
# Learn from this session: extract durable facts into memory.
|
|
220
|
+
# Only on the initial run (not follow-ups); best-effort, never
|
|
221
|
+
# raises.
|
|
222
|
+
extract_memories if reset && @memory_learning
|
|
202
223
|
# A pending tool completed while this run was busy: voice the
|
|
203
224
|
# result now that the turn is over (one follow-up per completion).
|
|
204
225
|
follow_up = @pending_mutex.synchronize do
|
|
@@ -474,6 +495,29 @@ module Ask
|
|
|
474
495
|
|
|
475
496
|
# --- Plan mode ---
|
|
476
497
|
|
|
498
|
+
# Extract durable facts from this session's transcript into memory
|
|
499
|
+
# (memory_learning: true). Best-effort — extraction never breaks the
|
|
500
|
+
# session; failures are swallowed.
|
|
501
|
+
def extract_memories
|
|
502
|
+
extractor = MemoryExtractor.new(model: model_id_from(@chat), memory: @memory)
|
|
503
|
+
extractor.extract(transcript: @chat.messages, session_id: @id)
|
|
504
|
+
rescue StandardError
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Retrieve memories relevant to the incoming message and inject them
|
|
509
|
+
# as a system message, so a new session starts with what earlier
|
|
510
|
+
# sessions learned.
|
|
511
|
+
def inject_memories(message)
|
|
512
|
+
hits = @memory.search(message.to_s, limit: 5)
|
|
513
|
+
return if hits.empty?
|
|
514
|
+
|
|
515
|
+
@chat.add_message(
|
|
516
|
+
role: :system,
|
|
517
|
+
content: "Relevant memories from previous sessions:\n" + hits.map { |e| "- #{e.content}" }.join("\n")
|
|
518
|
+
)
|
|
519
|
+
end
|
|
520
|
+
|
|
477
521
|
# @return [Boolean] whether the session is in plan mode (research
|
|
478
522
|
# phase; non-read-only tools are blocked until the plan is approved)
|
|
479
523
|
def plan_mode? = @plan_mode
|
|
@@ -724,6 +768,10 @@ module Ask
|
|
|
724
768
|
on_submit: ->(plan) { emit(Events::PlanProposed.new(plan: plan)) }
|
|
725
769
|
) unless resolved.any? { |t| t.name == "exit_plan_mode" }
|
|
726
770
|
end
|
|
771
|
+
if @memory
|
|
772
|
+
resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
|
|
773
|
+
resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
|
|
774
|
+
end
|
|
727
775
|
resolved
|
|
728
776
|
end
|
|
729
777
|
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -43,6 +43,10 @@ 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"
|
|
49
|
+
autoload :MemoryExtractor, "ask/agent/memory_extractor"
|
|
46
50
|
|
|
47
51
|
module Middleware
|
|
48
52
|
autoload :Base, "ask/agent/middleware/base"
|
|
@@ -77,14 +81,16 @@ module Ask
|
|
|
77
81
|
# Create a new agent session from a named definition.
|
|
78
82
|
#
|
|
79
83
|
# @param name [String, Symbol] the agent name (directory name under +agents/+)
|
|
84
|
+
# @param opts [Hash] runtime overrides — model, provider, api_key, api_base,
|
|
85
|
+
# max_turns, etc. Caller-supplied options win over the definition's config.
|
|
80
86
|
# @return [Session] a configured, ready-to-run session
|
|
81
|
-
def new(name)
|
|
87
|
+
def new(name, **opts)
|
|
82
88
|
discover!
|
|
83
89
|
entry = @registry[name.to_s]
|
|
84
90
|
raise UnknownAgent, "Unknown agent: #{name.inspect}. Searched agents/ and app/agents/." unless entry
|
|
85
91
|
|
|
86
92
|
klass, dir = entry
|
|
87
|
-
build_session_from_definition(klass, dir)
|
|
93
|
+
build_session_from_definition(klass, dir, opts)
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
# Force re-discovery of agent definitions.
|
|
@@ -179,7 +185,7 @@ module Ask
|
|
|
179
185
|
end
|
|
180
186
|
end
|
|
181
187
|
|
|
182
|
-
def build_session_from_definition(klass, dir)
|
|
188
|
+
def build_session_from_definition(klass, dir, opts = {})
|
|
183
189
|
config = klass._config
|
|
184
190
|
session_opts = { model: config[:model] || Ask::Agent.configuration.default_model }
|
|
185
191
|
|
|
@@ -215,6 +221,10 @@ module Ask
|
|
|
215
221
|
Ask::Agent.configuration.scheduler.every(schedule, name: File.basename(dir), &task_block)
|
|
216
222
|
end
|
|
217
223
|
|
|
224
|
+
# Caller-supplied runtime options (model/provider/api_key/api_base/...)
|
|
225
|
+
# win over the definition's config.
|
|
226
|
+
session_opts.merge!(opts) unless opts.empty?
|
|
227
|
+
|
|
218
228
|
Session.new(**session_opts)
|
|
219
229
|
end
|
|
220
230
|
|
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.35.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -178,6 +178,10 @@ 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_extractor.rb
|
|
183
|
+
- lib/ask/agent/memory_search.rb
|
|
184
|
+
- lib/ask/agent/memory_write.rb
|
|
181
185
|
- lib/ask/agent/meta_agent.rb
|
|
182
186
|
- lib/ask/agent/middleware/base.rb
|
|
183
187
|
- lib/ask/agent/middleware/default_settings.rb
|