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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90d637b9a007fef96f7b03e78ac61366ec3597bd789dfb3aaa36059687fb1632
4
- data.tar.gz: 48e9b46cd59bdbefa5e93dc2925c42c13f8b9ab85b389a2e4bf2b15b2c7add1e
3
+ metadata.gz: 2040f37b4e9b023374eb6169abd1f1c28fdda335a8032254a15b0d813a7cd0c9
4
+ data.tar.gz: 7212cbd265d8b054423cfad5ed76717eb36f1ff980c2a394cba9b0b4c256c3e9
5
5
  SHA512:
6
- metadata.gz: a1f76527ad518404c64c36c697db32fdebdb4b6381d86513e3aca4658486db238c2ab08a4df4df9ebdd6f0997fb9a5620c8d4d77bf69e9dbd70bb3235009d8a7
7
- data.tar.gz: b1697c33f4ca2dad32a05e716c060ec14baaf848e9b39f918a43a8917682fe7baf76c80ea583ffa44f648fe14c778d4b39fbb1d4237adae6718b78d14c6ea10f
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
@@ -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
 
@@ -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
- def initialize(state:, namespace:)
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
- @mutex = Mutex.new
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
@@ -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, **chat_options)
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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.33.0"
5
+ VERSION = "0.35.0"
6
6
  end
7
7
  end
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.33.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