ask-agent 0.33.0 → 0.36.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: fcbe9fc4bae7e7eddc9fc206e35ef22499a9fb3a308bac6d9f48c2d1d2e12f8f
4
+ data.tar.gz: ae9c72d61a839330d2bbc68b8049fe1e54680f0462d4dee2e886d8377df439b3
5
5
  SHA512:
6
- metadata.gz: a1f76527ad518404c64c36c697db32fdebdb4b6381d86513e3aca4658486db238c2ab08a4df4df9ebdd6f0997fb9a5620c8d4d77bf69e9dbd70bb3235009d8a7
7
- data.tar.gz: b1697c33f4ca2dad32a05e716c060ec14baaf848e9b39f918a43a8917682fe7baf76c80ea583ffa44f648fe14c778d4b39fbb1d4237adae6718b78d14c6ea10f
6
+ metadata.gz: 7a34904c0d8c0d6397753262d4f3c359674893b5ddab7a85714f8eebf797db0cdd3b6a076ea2c18eb693ad1f2211677e88bf4f4a7f4e2e2f7fe5545892af5062
7
+ data.tar.gz: d78f87319a5d6b63c22e1bd7d9cacde6fd6306131f746ad5b4166d509acf5dad11b03db7e9fab6f908db752f7a592f2a4dc51fdb13fb4fa30ebc39c193894acb
data/CHANGELOG.md CHANGED
@@ -1,3 +1,55 @@
1
+ ## [0.36.0] — 2026-08-07
2
+
3
+ ### Added
4
+
5
+ - **Large-output offloading — tool results never bloat the transcript.**
6
+ `Session.new(offload_large_outputs: true)` (or an Integer threshold,
7
+ default 4000 chars) stores tool messages above the threshold in a
8
+ state-backed store; the transcript keeps a short preview plus a reference
9
+ the model retrieves with the injected `output_read` tool:
10
+ - `Ask::Agent::ToolOutputStore` — pure KV on the same
11
+ `Ask::State::Adapter` as sessions/checkpoints/memory
12
+ (`output:<session_id>:<call_id>` + JSON index), works with every
13
+ backend; in-process Memory fallback when no `state:` is given. Stored
14
+ outputs are capped (`max_size:`, default 50,000 chars).
15
+ - `output_read` is exempt from offloading — its contract is to bring the
16
+ full output into context on demand.
17
+ - `Session#delete` cleans up the session's stored outputs.
18
+ - The loop now passes `session_id` to the tool executor (previously nil),
19
+ which offloading relies on.
20
+
21
+ ## [0.35.0] — 2026-08-07
22
+
23
+ ### Added
24
+
25
+ - **Memory learning — automatic extraction (v2 of durable memory).**
26
+ `Session.new(memory: memory, memory_learning: true)` extracts durable
27
+ facts from the transcript when the session ends — the model no longer has
28
+ to remember to call `memory_write`:
29
+ - `Ask::Agent::MemoryExtractor` reads the memory-relevant messages (user
30
+ + assistant, capped, oldest dropped), sends them to the model with a
31
+ configurable structured-output prompt, and writes the returned facts
32
+ into the store — deduped (exact + near-duplicate via search), stamped
33
+ with provenance (`extracted: true`, source session id), and capped
34
+ (`max_candidates:`, default 10).
35
+ - Extraction is best-effort: unparseable responses and failed calls
36
+ yield an empty result and never break the session.
37
+ - `Memory.new(max_entries:)` prunes the oldest entries once a namespace
38
+ exceeds the cap — bounded memory, not a growing dump.
39
+ - Requires `memory:`; `memory_learning:` without it raises.
40
+
41
+ ## [0.34.1] — 2026-08-07
42
+
43
+ ### Added
44
+
45
+ - **`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).
46
+
47
+ ## [0.34.0] — 2026-08-07
48
+
49
+ ### Added
50
+
51
+ - **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).
52
+
1
53
  ## [0.33.0] — 2026-08-06
2
54
 
3
55
  ### 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
 
@@ -99,6 +99,7 @@ module Ask
99
99
  # when the background work completes.
100
100
  user_results = tool_executor.execute(
101
101
  user_tool_calls, tools, hooks: hooks, event_emitter: event_emitter,
102
+ session_id: session_id,
102
103
  result_callback: lambda do |tool_call_id, result|
103
104
  tc = user_tool_calls[tool_call_id]
104
105
  next unless tc
@@ -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
@@ -0,0 +1,36 @@
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 retrieves an offloaded tool output by call id — the other
9
+ # half of large-output offloading. The transcript keeps a preview plus a
10
+ # reference ("output_read id: \"call_123\""); this tool fetches the full
11
+ # output from the session's {ToolOutputStore}.
12
+ #
13
+ # Injected into the session when large-output offloading is enabled.
14
+ class OutputRead < Ask::Tool
15
+ description "Retrieve the full output of a tool call that was truncated in the conversation. " \
16
+ "Use the id from the truncation note (e.g. output_read id: \"call_123\")."
17
+
18
+ param :id, type: :string, desc: "Tool call id from the truncation note", required: true
19
+
20
+ # @param store [Ask::Agent::ToolOutputStore]
21
+ # @param session_id [String] scopes lookups to this session
22
+ def initialize(store:, session_id:)
23
+ @store = store
24
+ @session_id = session_id
25
+ super()
26
+ end
27
+
28
+ def execute(id:)
29
+ output = @store.fetch(@session_id, id)
30
+ return Ask::Result.error(message: "No stored output for id #{id.inspect}") if output.nil?
31
+
32
+ Ask::Result.ok(data: output)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -24,7 +24,9 @@ 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, offload_large_outputs: false,
29
+ **chat_options)
28
30
  @id = id || SecureRandom.uuid
29
31
  @agent_dir = agent_dir
30
32
  @max_turns = max_turns
@@ -57,6 +59,24 @@ module Ask
57
59
  # Durable memory (memory_write / memory_search tools). An instance
58
60
  # with its own namespace and state adapter; nil disables memory.
59
61
  @memory = memory
62
+ # Learning: extract durable facts from the transcript when the
63
+ # session ends (requires memory).
64
+ if memory_learning && !@memory
65
+ raise ArgumentError, "memory_learning: requires a memory: instance"
66
+ end
67
+ @memory_learning = !!memory_learning
68
+
69
+ # Large-output offloading: tool results above a size threshold are
70
+ # stored in a ToolOutputStore (state adapter when present, else
71
+ # in-process) and the transcript keeps a preview + reference.
72
+ @offload_threshold = case offload_large_outputs
73
+ when true then 4000
74
+ when Integer then offload_large_outputs
75
+ else nil
76
+ end
77
+ @output_store = if @offload_threshold
78
+ ToolOutputStore.new(state: state || persistence || Ask::State::Memory.new)
79
+ end
60
80
 
61
81
  # Plan mode — research phase gated to read-only tools until a human
62
82
  # approves the model's plan (submitted via the exit_plan_mode tool).
@@ -74,7 +94,12 @@ module Ask
74
94
  @tools = resolve_tools(tools)
75
95
  @chat = build_chat(model, system_prompt, @tools, **chat_options)
76
96
  @loop = Loop.new(max_turns: max_turns)
77
- @tool_executor = ToolExecutor.new(max_retries: max_tool_retries, parallel: parallel_tools)
97
+ @tool_executor = ToolExecutor.new(
98
+ max_retries: max_tool_retries,
99
+ parallel: parallel_tools,
100
+ output_offload_threshold: @offload_threshold,
101
+ output_store: @output_store
102
+ )
78
103
  @compactor = compactor ? build_compactor(compactor) : nil
79
104
  @hooks = Hooks.new(hooks)
80
105
  @audit_log = build_audit_log(audit_log)
@@ -144,6 +169,9 @@ module Ask
144
169
  # @return [Ask::Agent::Memory, nil] durable memory (only when passed
145
170
  # via the +memory:+ option)
146
171
  attr_reader :memory
172
+ # @return [Ask::Agent::ToolOutputStore, nil] store for offloaded large
173
+ # tool outputs (only when large-output offloading is enabled)
174
+ attr_reader :output_store
147
175
 
148
176
  def run(message, tools: nil, reset: true)
149
177
  raise "Session deleted" if @deleted
@@ -209,6 +237,10 @@ module Ask
209
237
  @running = false
210
238
  Ask::Agent.current_session = nil if Ask::Agent.current_session.equal?(self)
211
239
  persist! if @state
240
+ # Learn from this session: extract durable facts into memory.
241
+ # Only on the initial run (not follow-ups); best-effort, never
242
+ # raises.
243
+ extract_memories if reset && @memory_learning
212
244
  # A pending tool completed while this run was busy: voice the
213
245
  # result now that the turn is over (one follow-up per completion).
214
246
  follow_up = @pending_mutex.synchronize do
@@ -396,6 +428,7 @@ module Ask
396
428
  def delete
397
429
  @deleted = true
398
430
  @checkpoint_store&.delete(@id)
431
+ @output_store&.delete(@id)
399
432
  @state&.delete(@id)
400
433
  end
401
434
 
@@ -484,6 +517,16 @@ module Ask
484
517
 
485
518
  # --- Plan mode ---
486
519
 
520
+ # Extract durable facts from this session's transcript into memory
521
+ # (memory_learning: true). Best-effort — extraction never breaks the
522
+ # session; failures are swallowed.
523
+ def extract_memories
524
+ extractor = MemoryExtractor.new(model: model_id_from(@chat), memory: @memory)
525
+ extractor.extract(transcript: @chat.messages, session_id: @id)
526
+ rescue StandardError
527
+ nil
528
+ end
529
+
487
530
  # Retrieve memories relevant to the incoming message and inject them
488
531
  # as a system message, so a new session starts with what earlier
489
532
  # sessions learned.
@@ -751,6 +794,9 @@ module Ask
751
794
  resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
752
795
  resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
753
796
  end
797
+ if @output_store
798
+ resolved << OutputRead.new(store: @output_store, session_id: @id) unless resolved.any? { |t| t.name == "output_read" }
799
+ end
754
800
  resolved
755
801
  end
756
802
 
@@ -11,10 +11,12 @@ module Ask
11
11
 
12
12
  attr_reader :total_executions
13
13
 
14
- def initialize(max_retries: 3, parallel: true)
14
+ def initialize(max_retries: 3, parallel: true, output_offload_threshold: nil, output_store: nil)
15
15
  @max_retries = max_retries
16
16
  @parallel = parallel
17
17
  @total_executions = 0
18
+ @output_offload_threshold = output_offload_threshold
19
+ @output_store = output_store
18
20
  end
19
21
 
20
22
  attr_writer :telemetry
@@ -185,6 +187,15 @@ module Ask
185
187
  result[:result].to_s
186
188
  end
187
189
 
190
+ # Large outputs never enter the transcript: store the full message
191
+ # and keep a short preview plus a reference the model can retrieve
192
+ # with the output_read tool. output_read's own result is exempt —
193
+ # its contract is to bring the full output into context on demand.
194
+ if @output_offload_threshold && message.length > @output_offload_threshold &&
195
+ tool_call.name != "output_read"
196
+ message = offload_message(message, tool_call.id)
197
+ end
198
+
188
199
  inner = result[:result]
189
200
  status = if result[:is_error] == true
190
201
  "error"
@@ -236,6 +247,15 @@ module Ask
236
247
  { result: e.message, is_error: true, error: e.class.name }
237
248
  end
238
249
 
250
+ # Store a large tool message in the output store and return a short
251
+ # preview that references it, so the transcript never carries the full
252
+ # output.
253
+ def offload_message(message, tool_call_id)
254
+ @output_store.store(@session_id, tool_call_id, message)
255
+ preview = message[0, 300]
256
+ "#{preview}\n...(output truncated: #{message.length} chars — full output via output_read id: \"#{tool_call_id}\")"
257
+ end
258
+
239
259
  def retryable_error_name?(error_name)
240
260
  return false unless error_name
241
261
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Agent
7
+ # State-backed storage for large tool outputs, keeping them out of the
8
+ # conversation transcript.
9
+ #
10
+ # When a tool result exceeds the session's offload threshold, the
11
+ # executor stores the full output here and the transcript keeps a short
12
+ # preview plus a reference the model can retrieve with the output_read
13
+ # tool (and the web UI can fetch from the same store).
14
+ #
15
+ # Storage shape (pure KV — works with every Ask::State::Adapter backend
16
+ # including custom get/set/delete adapters):
17
+ # output:<session_id>:<call_id> — one key per offloaded output
18
+ # output:<session_id>:index — JSON array of call ids (write order)
19
+ #
20
+ # store = Ask::Agent::ToolOutputStore.new(state: adapter)
21
+ # store.store(session_id, "call_1", huge_output)
22
+ # store.fetch(session_id, "call_1") # => huge_output
23
+ # store.delete(session_id) # session cleanup
24
+ class ToolOutputStore
25
+ KEY_PREFIX = "output:"
26
+ INDEX_SUFFIX = ":index"
27
+
28
+ # @param state [Ask::State::Adapter] backing store
29
+ # @param max_size [Integer] stored outputs are truncated to this many
30
+ # characters (with a truncation marker)
31
+ def initialize(state:, max_size: 50_000)
32
+ @state = state
33
+ @max_size = max_size
34
+ @mutex = Monitor.new
35
+ end
36
+
37
+ # @return [Ask::State::Adapter] the underlying adapter
38
+ attr_reader :state
39
+
40
+ # Store an output for a tool call (idempotent per call id — a later
41
+ # store with the same call id replaces the earlier one).
42
+ #
43
+ # @param session_id [String]
44
+ # @param call_id [String]
45
+ # @param content [String]
46
+ # @return [String] the stored content (possibly truncated)
47
+ def store(session_id, call_id, content)
48
+ stored = content.to_s
49
+ stored = "#{stored[0, @max_size]}\n...(output truncated)" if stored.length > @max_size
50
+
51
+ @mutex.synchronize do
52
+ @state.set(entry_key(session_id, call_id), stored)
53
+ index = load_index(session_id)
54
+ @state.set(index_key(session_id), (index + [call_id]).uniq.to_json)
55
+ end
56
+ stored
57
+ end
58
+
59
+ # @param session_id [String]
60
+ # @param call_id [String]
61
+ # @return [String, nil] the stored output, or nil when absent
62
+ def fetch(session_id, call_id)
63
+ @state.get(entry_key(session_id, call_id))
64
+ end
65
+
66
+ # Remove every output for a session (called by Session#delete).
67
+ #
68
+ # @param session_id [String]
69
+ # @return [void]
70
+ def delete(session_id)
71
+ @mutex.synchronize do
72
+ load_index(session_id).each { |call_id| @state.delete(entry_key(session_id, call_id)) }
73
+ @state.delete(index_key(session_id))
74
+ end
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ def load_index(session_id)
81
+ raw = @state.get(index_key(session_id))
82
+ raw ? JSON.parse(raw) : []
83
+ end
84
+
85
+ def entry_key(session_id, call_id)
86
+ "#{KEY_PREFIX}#{session_id}:#{call_id}"
87
+ end
88
+
89
+ def index_key(session_id)
90
+ "#{KEY_PREFIX}#{session_id}#{INDEX_SUFFIX}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.33.0"
5
+ VERSION = "0.36.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -46,6 +46,9 @@ 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"
50
+ autoload :ToolOutputStore, "ask/agent/tool_output_store"
51
+ autoload :OutputRead, "ask/agent/output_read"
49
52
 
50
53
  module Middleware
51
54
  autoload :Base, "ask/agent/middleware/base"
@@ -80,14 +83,16 @@ module Ask
80
83
  # Create a new agent session from a named definition.
81
84
  #
82
85
  # @param name [String, Symbol] the agent name (directory name under +agents/+)
86
+ # @param opts [Hash] runtime overrides — model, provider, api_key, api_base,
87
+ # max_turns, etc. Caller-supplied options win over the definition's config.
83
88
  # @return [Session] a configured, ready-to-run session
84
- def new(name)
89
+ def new(name, **opts)
85
90
  discover!
86
91
  entry = @registry[name.to_s]
87
92
  raise UnknownAgent, "Unknown agent: #{name.inspect}. Searched agents/ and app/agents/." unless entry
88
93
 
89
94
  klass, dir = entry
90
- build_session_from_definition(klass, dir)
95
+ build_session_from_definition(klass, dir, opts)
91
96
  end
92
97
 
93
98
  # Force re-discovery of agent definitions.
@@ -182,7 +187,7 @@ module Ask
182
187
  end
183
188
  end
184
189
 
185
- def build_session_from_definition(klass, dir)
190
+ def build_session_from_definition(klass, dir, opts = {})
186
191
  config = klass._config
187
192
  session_opts = { model: config[:model] || Ask::Agent.configuration.default_model }
188
193
 
@@ -218,6 +223,10 @@ module Ask
218
223
  Ask::Agent.configuration.scheduler.every(schedule, name: File.basename(dir), &task_block)
219
224
  end
220
225
 
226
+ # Caller-supplied runtime options (model/provider/api_key/api_base/...)
227
+ # win over the definition's config.
228
+ session_opts.merge!(opts) unless opts.empty?
229
+
221
230
  Session.new(**session_opts)
222
231
  end
223
232
 
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.36.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
@@ -188,6 +189,7 @@ files:
188
189
  - lib/ask/agent/middleware/model_fallback.rb
189
190
  - lib/ask/agent/middleware/pipeline.rb
190
191
  - lib/ask/agent/middleware/retry_on_failure.rb
192
+ - lib/ask/agent/output_read.rb
191
193
  - lib/ask/agent/persistence/base.rb
192
194
  - lib/ask/agent/persistence/in_memory.rb
193
195
  - lib/ask/agent/policies/approval_policy.rb
@@ -214,6 +216,7 @@ files:
214
216
  - lib/ask/agent/tool_abort_controller.rb
215
217
  - lib/ask/agent/tool_call_repair.rb
216
218
  - lib/ask/agent/tool_executor.rb
219
+ - lib/ask/agent/tool_output_store.rb
217
220
  - lib/ask/agent/version.rb
218
221
  homepage: https://github.com/ask-rb/ask-agent
219
222
  licenses: