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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2040f37b4e9b023374eb6169abd1f1c28fdda335a8032254a15b0d813a7cd0c9
4
- data.tar.gz: 7212cbd265d8b054423cfad5ed76717eb36f1ff980c2a394cba9b0b4c256c3e9
3
+ metadata.gz: fcbe9fc4bae7e7eddc9fc206e35ef22499a9fb3a308bac6d9f48c2d1d2e12f8f
4
+ data.tar.gz: ae9c72d61a839330d2bbc68b8049fe1e54680f0462d4dee2e886d8377df439b3
5
5
  SHA512:
6
- metadata.gz: 1cb0c3e65bd08e9c45f93bdc378149810d7c31594f63cf69799e128b9f32496f3e71ca521cbe43d6729a9d39210bce0469a7eb6686336a45f2677fc473053cad
7
- data.tar.gz: 2b6d47cfb37af4c3a2d850a551621af6d44542dd88f9483a695cfcd83b58f2bb893a7403ac91b3a01f3659936ad8f4df5c8d7cc8eb7656c22e6587db0bba0081
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
@@ -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
@@ -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, **chat_options)
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(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
+ )
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.35.0"
5
+ VERSION = "0.36.0"
6
6
  end
7
7
  end
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.35.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: