ask-agent 0.33.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 +32 -0
- data/lib/ask/agent/chat.rb +7 -3
- data/lib/ask/agent/memory.rb +16 -2
- data/lib/ask/agent/memory_extractor.rb +118 -0
- data/lib/ask/agent/session.rb +22 -1
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +10 -3
- metadata +2 -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,35 @@
|
|
|
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
|
+
|
|
1
33
|
## [0.33.0] — 2026-08-06
|
|
2
34
|
|
|
3
35
|
### 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
|
|
data/lib/ask/agent/memory.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "monitor"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require "time"
|
|
6
7
|
|
|
@@ -37,10 +38,13 @@ module Ask
|
|
|
37
38
|
|
|
38
39
|
# @param state [Ask::State::Adapter] backing store
|
|
39
40
|
# @param namespace [String] isolation scope (user id, project id, ...)
|
|
40
|
-
|
|
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)
|
|
41
44
|
@state = state
|
|
42
45
|
@namespace = namespace.to_s
|
|
43
|
-
@
|
|
46
|
+
@max_entries = max_entries
|
|
47
|
+
@mutex = Monitor.new
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
# @return [Ask::State::Adapter] the underlying adapter
|
|
@@ -67,6 +71,7 @@ module Ask
|
|
|
67
71
|
entry = Entry.new(id: SecureRandom.uuid, content: content, metadata: metadata, created_at: Time.now)
|
|
68
72
|
@state.set(entry_key(entry.id), entry.to_h)
|
|
69
73
|
@state.set(index_key, (load_index + [entry.id]).to_json)
|
|
74
|
+
prune_oldest if @max_entries
|
|
70
75
|
entry
|
|
71
76
|
end
|
|
72
77
|
end
|
|
@@ -115,6 +120,15 @@ module Ask
|
|
|
115
120
|
|
|
116
121
|
private
|
|
117
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
|
+
|
|
118
132
|
def entries
|
|
119
133
|
load_index.filter_map { |id| load_entry(id) }
|
|
120
134
|
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
|
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, memory: nil,
|
|
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
|
|
@@ -57,6 +58,12 @@ module Ask
|
|
|
57
58
|
# Durable memory (memory_write / memory_search tools). An instance
|
|
58
59
|
# with its own namespace and state adapter; nil disables memory.
|
|
59
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
|
|
60
67
|
|
|
61
68
|
# Plan mode — research phase gated to read-only tools until a human
|
|
62
69
|
# approves the model's plan (submitted via the exit_plan_mode tool).
|
|
@@ -209,6 +216,10 @@ module Ask
|
|
|
209
216
|
@running = false
|
|
210
217
|
Ask::Agent.current_session = nil if Ask::Agent.current_session.equal?(self)
|
|
211
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
|
|
212
223
|
# A pending tool completed while this run was busy: voice the
|
|
213
224
|
# result now that the turn is over (one follow-up per completion).
|
|
214
225
|
follow_up = @pending_mutex.synchronize do
|
|
@@ -484,6 +495,16 @@ module Ask
|
|
|
484
495
|
|
|
485
496
|
# --- Plan mode ---
|
|
486
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
|
+
|
|
487
508
|
# Retrieve memories relevant to the incoming message and inject them
|
|
488
509
|
# as a system message, so a new session starts with what earlier
|
|
489
510
|
# sessions learned.
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -46,6 +46,7 @@ module Ask
|
|
|
46
46
|
autoload :Memory, "ask/agent/memory"
|
|
47
47
|
autoload :MemoryWrite, "ask/agent/memory_write"
|
|
48
48
|
autoload :MemorySearch, "ask/agent/memory_search"
|
|
49
|
+
autoload :MemoryExtractor, "ask/agent/memory_extractor"
|
|
49
50
|
|
|
50
51
|
module Middleware
|
|
51
52
|
autoload :Base, "ask/agent/middleware/base"
|
|
@@ -80,14 +81,16 @@ module Ask
|
|
|
80
81
|
# Create a new agent session from a named definition.
|
|
81
82
|
#
|
|
82
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.
|
|
83
86
|
# @return [Session] a configured, ready-to-run session
|
|
84
|
-
def new(name)
|
|
87
|
+
def new(name, **opts)
|
|
85
88
|
discover!
|
|
86
89
|
entry = @registry[name.to_s]
|
|
87
90
|
raise UnknownAgent, "Unknown agent: #{name.inspect}. Searched agents/ and app/agents/." unless entry
|
|
88
91
|
|
|
89
92
|
klass, dir = entry
|
|
90
|
-
build_session_from_definition(klass, dir)
|
|
93
|
+
build_session_from_definition(klass, dir, opts)
|
|
91
94
|
end
|
|
92
95
|
|
|
93
96
|
# Force re-discovery of agent definitions.
|
|
@@ -182,7 +185,7 @@ module Ask
|
|
|
182
185
|
end
|
|
183
186
|
end
|
|
184
187
|
|
|
185
|
-
def build_session_from_definition(klass, dir)
|
|
188
|
+
def build_session_from_definition(klass, dir, opts = {})
|
|
186
189
|
config = klass._config
|
|
187
190
|
session_opts = { model: config[:model] || Ask::Agent.configuration.default_model }
|
|
188
191
|
|
|
@@ -218,6 +221,10 @@ module Ask
|
|
|
218
221
|
Ask::Agent.configuration.scheduler.every(schedule, name: File.basename(dir), &task_block)
|
|
219
222
|
end
|
|
220
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
|
+
|
|
221
228
|
Session.new(**session_opts)
|
|
222
229
|
end
|
|
223
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
|
|
@@ -179,6 +179,7 @@ files:
|
|
|
179
179
|
- lib/ask/agent/hooks.rb
|
|
180
180
|
- lib/ask/agent/loop.rb
|
|
181
181
|
- lib/ask/agent/memory.rb
|
|
182
|
+
- lib/ask/agent/memory_extractor.rb
|
|
182
183
|
- lib/ask/agent/memory_search.rb
|
|
183
184
|
- lib/ask/agent/memory_write.rb
|
|
184
185
|
- lib/ask/agent/meta_agent.rb
|