ask-agent 0.35.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 +20 -0
- data/lib/ask/agent/loop.rb +1 -0
- data/lib/ask/agent/output_read.rb +36 -0
- data/lib/ask/agent/session.rb +27 -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 +2 -0
- metadata +3 -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,23 @@
|
|
|
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
|
+
|
|
1
21
|
## [0.35.0] — 2026-08-07
|
|
2
22
|
|
|
3
23
|
### Added
|
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
|
|
@@ -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
|
@@ -25,7 +25,8 @@ module Ask
|
|
|
25
25
|
skills_disclosure: true, approval: nil,
|
|
26
26
|
tool_call_repair: nil, checkpoints: false,
|
|
27
27
|
todos: false, plan_mode: false, memory: nil,
|
|
28
|
-
memory_learning: false,
|
|
28
|
+
memory_learning: false, offload_large_outputs: false,
|
|
29
|
+
**chat_options)
|
|
29
30
|
@id = id || SecureRandom.uuid
|
|
30
31
|
@agent_dir = agent_dir
|
|
31
32
|
@max_turns = max_turns
|
|
@@ -65,6 +66,18 @@ module Ask
|
|
|
65
66
|
end
|
|
66
67
|
@memory_learning = !!memory_learning
|
|
67
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
|
|
80
|
+
|
|
68
81
|
# Plan mode — research phase gated to read-only tools until a human
|
|
69
82
|
# approves the model's plan (submitted via the exit_plan_mode tool).
|
|
70
83
|
@plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
|
|
@@ -81,7 +94,12 @@ module Ask
|
|
|
81
94
|
@tools = resolve_tools(tools)
|
|
82
95
|
@chat = build_chat(model, system_prompt, @tools, **chat_options)
|
|
83
96
|
@loop = Loop.new(max_turns: max_turns)
|
|
84
|
-
@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
|
+
)
|
|
85
103
|
@compactor = compactor ? build_compactor(compactor) : nil
|
|
86
104
|
@hooks = Hooks.new(hooks)
|
|
87
105
|
@audit_log = build_audit_log(audit_log)
|
|
@@ -151,6 +169,9 @@ module Ask
|
|
|
151
169
|
# @return [Ask::Agent::Memory, nil] durable memory (only when passed
|
|
152
170
|
# via the +memory:+ option)
|
|
153
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
|
|
154
175
|
|
|
155
176
|
def run(message, tools: nil, reset: true)
|
|
156
177
|
raise "Session deleted" if @deleted
|
|
@@ -407,6 +428,7 @@ module Ask
|
|
|
407
428
|
def delete
|
|
408
429
|
@deleted = true
|
|
409
430
|
@checkpoint_store&.delete(@id)
|
|
431
|
+
@output_store&.delete(@id)
|
|
410
432
|
@state&.delete(@id)
|
|
411
433
|
end
|
|
412
434
|
|
|
@@ -772,6 +794,9 @@ module Ask
|
|
|
772
794
|
resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
|
|
773
795
|
resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
|
|
774
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
|
|
775
800
|
resolved
|
|
776
801
|
end
|
|
777
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
|
@@ -47,6 +47,8 @@ module Ask
|
|
|
47
47
|
autoload :MemoryWrite, "ask/agent/memory_write"
|
|
48
48
|
autoload :MemorySearch, "ask/agent/memory_search"
|
|
49
49
|
autoload :MemoryExtractor, "ask/agent/memory_extractor"
|
|
50
|
+
autoload :ToolOutputStore, "ask/agent/tool_output_store"
|
|
51
|
+
autoload :OutputRead, "ask/agent/output_read"
|
|
50
52
|
|
|
51
53
|
module Middleware
|
|
52
54
|
autoload :Base, "ask/agent/middleware/base"
|
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
|
|
@@ -189,6 +189,7 @@ files:
|
|
|
189
189
|
- lib/ask/agent/middleware/model_fallback.rb
|
|
190
190
|
- lib/ask/agent/middleware/pipeline.rb
|
|
191
191
|
- lib/ask/agent/middleware/retry_on_failure.rb
|
|
192
|
+
- lib/ask/agent/output_read.rb
|
|
192
193
|
- lib/ask/agent/persistence/base.rb
|
|
193
194
|
- lib/ask/agent/persistence/in_memory.rb
|
|
194
195
|
- lib/ask/agent/policies/approval_policy.rb
|
|
@@ -215,6 +216,7 @@ files:
|
|
|
215
216
|
- lib/ask/agent/tool_abort_controller.rb
|
|
216
217
|
- lib/ask/agent/tool_call_repair.rb
|
|
217
218
|
- lib/ask/agent/tool_executor.rb
|
|
219
|
+
- lib/ask/agent/tool_output_store.rb
|
|
218
220
|
- lib/ask/agent/version.rb
|
|
219
221
|
homepage: https://github.com/ask-rb/ask-agent
|
|
220
222
|
licenses:
|