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 +4 -4
- data/CHANGELOG.md +52 -0
- data/lib/ask/agent/chat.rb +7 -3
- data/lib/ask/agent/loop.rb +1 -0
- data/lib/ask/agent/memory.rb +16 -2
- data/lib/ask/agent/memory_extractor.rb +118 -0
- data/lib/ask/agent/output_read.rb +36 -0
- data/lib/ask/agent/session.rb +48 -2
- data/lib/ask/agent/tool_executor.rb +21 -1
- data/lib/ask/agent/tool_output_store.rb +94 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +12 -3
- 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: fcbe9fc4bae7e7eddc9fc206e35ef22499a9fb3a308bac6d9f48c2d1d2e12f8f
|
|
4
|
+
data.tar.gz: ae9c72d61a839330d2bbc68b8049fe1e54680f0462d4dee2e886d8377df439b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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/loop.rb
CHANGED
|
@@ -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
|
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
|
|
@@ -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
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
data/lib/ask/agent/version.rb
CHANGED
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.
|
|
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:
|