ask-agent 0.35.0 → 0.37.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: e22262139bd8142b9e45f22e831d59360f9465aad8fea93247352591b742d6c3
4
+ data.tar.gz: 1486719187105e25f794bff150fd9aa59e902096a40c15fdb822b2e7061d9bac
5
5
  SHA512:
6
- metadata.gz: 1cb0c3e65bd08e9c45f93bdc378149810d7c31594f63cf69799e128b9f32496f3e71ca521cbe43d6729a9d39210bce0469a7eb6686336a45f2677fc473053cad
7
- data.tar.gz: 2b6d47cfb37af4c3a2d850a551621af6d44542dd88f9483a695cfcd83b58f2bb893a7403ac91b3a01f3659936ad8f4df5c8d7cc8eb7656c22e6587db0bba0081
6
+ metadata.gz: 2eaacd8c0a7a32392546ccde9d218d271afa22a3a2d1c154bfc3665691060b9cc4e7ab8d658c25eb2b2e79cccb1ad3bbbf10cd65e36c4324e2fc408f8eb8d973
7
+ data.tar.gz: d9abb9f16f260d5ece0c403346ae0f1cb5a9edadb3e9fad0a2a56fd05c66748eb0070f16efa8441aa55275c5fc1aff724d43f0d4d6809751b932978272563fcf
data/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
1
+ ## [0.37.0] — 2026-08-07
2
+
3
+ ### Added
4
+
5
+ - **Steer — concurrency-safe message injection.**
6
+ `Session#steer(message, expected_turn_id:)` lets any thread (web, CLI,
7
+ another agent) inject a message safely:
8
+ - **`:stale`** — `expected_turn_id` doesn't match the current turn id
9
+ (the caller was looking at an older state); the message is rejected.
10
+ - **`:queued`** — a turn is running; the message is held and dispatched
11
+ as the next user message at the next turn boundary (the loop now
12
+ resolves each recursive turn's message from a steer source). No more
13
+ abort-and-retry.
14
+ - **`:steered`** — the session is idle; the message enters the
15
+ conversation and the next run processes it. Queued leftovers drain at
16
+ the next run start.
17
+ - `Session#turn_id` tracks the running turn (bumped on `TurnStart`);
18
+ `Session#queued_steers` reports pending messages.
19
+
20
+ ### Fixed
21
+
22
+ - **No more duplicate tail checkpoints.** The loop persists after every
23
+ turn and `run()` persists again on the way out, so every run previously
24
+ appended a redundant checkpoint (seqs 1,2,3 for two turns). `persist!`
25
+ now skips the checkpoint when the message count and turn count are
26
+ unchanged — `checkpoint_history` is exact.
27
+
28
+ ## [0.36.0] — 2026-08-07
29
+
30
+ ### Added
31
+
32
+ - **Large-output offloading — tool results never bloat the transcript.**
33
+ `Session.new(offload_large_outputs: true)` (or an Integer threshold,
34
+ default 4000 chars) stores tool messages above the threshold in a
35
+ state-backed store; the transcript keeps a short preview plus a reference
36
+ the model retrieves with the injected `output_read` tool:
37
+ - `Ask::Agent::ToolOutputStore` — pure KV on the same
38
+ `Ask::State::Adapter` as sessions/checkpoints/memory
39
+ (`output:<session_id>:<call_id>` + JSON index), works with every
40
+ backend; in-process Memory fallback when no `state:` is given. Stored
41
+ outputs are capped (`max_size:`, default 50,000 chars).
42
+ - `output_read` is exempt from offloading — its contract is to bring the
43
+ full output into context on demand.
44
+ - `Session#delete` cleans up the session's stored outputs.
45
+ - The loop now passes `session_id` to the tool executor (previously nil),
46
+ which offloading relies on.
47
+
1
48
  ## [0.35.0] — 2026-08-07
2
49
 
3
50
  ### Added
@@ -17,7 +17,7 @@ module Ask
17
17
  @max_consecutive_tool_turns = max_consecutive_tool_turns
18
18
  end
19
19
 
20
- def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil)
20
+ def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil, steer_source: nil)
21
21
  raise MaxTurnsExceeded if @turn_count >= @max_turns
22
22
 
23
23
  event_emitter.emit(Events::TurnStart.new)
@@ -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
@@ -156,10 +157,12 @@ module Ask
156
157
  # Aborted while tools ran? Skip the follow-up LLM call.
157
158
  return response.content.to_s if aborted?(event_emitter)
158
159
 
159
- # Recursive call — LLM processes tool results
160
+ # Recursive call — LLM processes tool results. When a steer source
161
+ # is provided, queued steer messages become the next user message
162
+ # instead of an empty continuation.
160
163
  run_turn(
161
164
  chat: chat,
162
- message: "",
165
+ message: steer_source ? steer_source.call.to_s : "",
163
166
  tools: tools,
164
167
  tool_executor: tool_executor,
165
168
  compactor: compactor,
@@ -167,7 +170,8 @@ module Ask
167
170
  event_emitter: event_emitter,
168
171
  session_id: session_id,
169
172
  persist: persist,
170
- tool_call_repair: tool_call_repair
173
+ tool_call_repair: tool_call_repair,
174
+ steer_source: steer_source
171
175
  )
172
176
  end
173
177
 
@@ -5,8 +5,7 @@ require "json"
5
5
  module Ask
6
6
  module Agent
7
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).
8
+ # them to the session's {Memory} — the "learning" half of durable memory.
10
9
  #
11
10
  # One LLM call with a structured-output prompt: the model reads the
12
11
  # memory-relevant messages and returns a JSON list of durable facts.
@@ -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
@@ -40,6 +41,13 @@ module Ask
40
41
  @pending_mutex = Mutex.new
41
42
  @followup_pending = false
42
43
  @turn_count = 0
44
+ # Concurrency-safe steering: turn id bumped at every
45
+ # TurnStart; steers arriving mid-turn are queued and dispatched at
46
+ # the next turn boundary.
47
+ @turn_id = 0
48
+ @queued_steers = []
49
+ @steer_mutex = Mutex.new
50
+ on(Events::TurnStart) { @turn_id += 1 }
43
51
  @created_at = Time.now
44
52
  @_no_tools_instructed = false
45
53
 
@@ -65,6 +73,18 @@ module Ask
65
73
  end
66
74
  @memory_learning = !!memory_learning
67
75
 
76
+ # Large-output offloading: tool results above a size threshold are
77
+ # stored in a ToolOutputStore (state adapter when present, else
78
+ # in-process) and the transcript keeps a preview + reference.
79
+ @offload_threshold = case offload_large_outputs
80
+ when true then 4000
81
+ when Integer then offload_large_outputs
82
+ else nil
83
+ end
84
+ @output_store = if @offload_threshold
85
+ ToolOutputStore.new(state: state || persistence || Ask::State::Memory.new)
86
+ end
87
+
68
88
  # Plan mode — research phase gated to read-only tools until a human
69
89
  # approves the model's plan (submitted via the exit_plan_mode tool).
70
90
  @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
@@ -81,7 +101,12 @@ module Ask
81
101
  @tools = resolve_tools(tools)
82
102
  @chat = build_chat(model, system_prompt, @tools, **chat_options)
83
103
  @loop = Loop.new(max_turns: max_turns)
84
- @tool_executor = ToolExecutor.new(max_retries: max_tool_retries, parallel: parallel_tools)
104
+ @tool_executor = ToolExecutor.new(
105
+ max_retries: max_tool_retries,
106
+ parallel: parallel_tools,
107
+ output_offload_threshold: @offload_threshold,
108
+ output_store: @output_store
109
+ )
85
110
  @compactor = compactor ? build_compactor(compactor) : nil
86
111
  @hooks = Hooks.new(hooks)
87
112
  @audit_log = build_audit_log(audit_log)
@@ -151,6 +176,9 @@ module Ask
151
176
  # @return [Ask::Agent::Memory, nil] durable memory (only when passed
152
177
  # via the +memory:+ option)
153
178
  attr_reader :memory
179
+ # @return [Ask::Agent::ToolOutputStore, nil] store for offloaded large
180
+ # tool outputs (only when large-output offloading is enabled)
181
+ attr_reader :output_store
154
182
 
155
183
  def run(message, tools: nil, reset: true)
156
184
  raise "Session deleted" if @deleted
@@ -176,6 +204,10 @@ module Ask
176
204
  @_no_tools_instructed = true
177
205
  end
178
206
 
207
+ # Leftover queued steers from a previous run become user messages
208
+ # before this run starts.
209
+ drain_leftover_steers
210
+
179
211
  begin
180
212
  @tool_executor.telemetry = @telemetry
181
213
 
@@ -189,6 +221,7 @@ module Ask
189
221
  event_emitter: self,
190
222
  session_id: @id,
191
223
  tool_call_repair: @tool_call_repair,
224
+ steer_source: method(:drain_one_steer),
192
225
  persist: @state ? method(:persist!) : nil
193
226
  )
194
227
 
@@ -407,6 +440,7 @@ module Ask
407
440
  def delete
408
441
  @deleted = true
409
442
  @checkpoint_store&.delete(@id)
443
+ @output_store&.delete(@id)
410
444
  @state&.delete(@id)
411
445
  end
412
446
 
@@ -495,6 +529,20 @@ module Ask
495
529
 
496
530
  # --- Plan mode ---
497
531
 
532
+ # Pop the next queued steer (called by the loop at each turn
533
+ # boundary); returns "" when nothing is queued.
534
+ def drain_one_steer
535
+ @steer_mutex.synchronize { @queued_steers.shift }.to_s
536
+ end
537
+
538
+ # Move any queued steers left over from a previous run into the
539
+ # conversation (the session was idle, so they are dispatched now).
540
+ def drain_leftover_steers
541
+ while (message = drain_one_steer) != ""
542
+ @chat.add_message(role: :user, content: message)
543
+ end
544
+ end
545
+
498
546
  # Extract durable facts from this session's transcript into memory
499
547
  # (memory_learning: true). Best-effort — extraction never breaks the
500
548
  # session; failures are swallowed.
@@ -560,6 +608,45 @@ module Ask
560
608
  )
561
609
  end
562
610
 
611
+ # --- Steer (concurrency-safe message injection) ---
612
+
613
+ # @return [Integer] id of the turn currently running (or the last
614
+ # completed turn when idle)
615
+ attr_reader :turn_id
616
+
617
+ # Inject a message into the session safely, from any thread (web, CLI,
618
+ # another agent):
619
+ #
620
+ # - **:stale** — the caller's `expected_turn_id` does not match the
621
+ # current turn id (the caller was looking at an older state).
622
+ # - **:queued** — a turn is running; the message is held and dispatched
623
+ # as the next user message at the next turn boundary.
624
+ # - **:steered** — the session is idle; the message is added to the
625
+ # conversation and processed by the next run.
626
+ #
627
+ # @param message [String]
628
+ # @param expected_turn_id [Integer, nil] the turn id the caller
629
+ # believes is current; nil skips the check
630
+ # @return [Hash] {status: :stale|:queued|:steered, turn_id: Integer}
631
+ def steer(message, expected_turn_id: nil)
632
+ @steer_mutex.synchronize do
633
+ if expected_turn_id && expected_turn_id != @turn_id
634
+ return { status: :stale, turn_id: @turn_id }
635
+ end
636
+ if @running
637
+ @queued_steers << message.to_s
638
+ return { status: :queued, turn_id: @turn_id }
639
+ end
640
+ end
641
+ @chat.add_message(role: :user, content: message.to_s)
642
+ { status: :steered, turn_id: @turn_id }
643
+ end
644
+
645
+ # @return [Integer] steers queued and not yet dispatched
646
+ def queued_steers
647
+ @steer_mutex.synchronize { @queued_steers.size }
648
+ end
649
+
563
650
  # --- Async (pending) tools ---
564
651
 
565
652
  # Registers a pending tool call (called by the loop when a tool
@@ -772,6 +859,9 @@ module Ask
772
859
  resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
773
860
  resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
774
861
  end
862
+ if @output_store
863
+ resolved << OutputRead.new(store: @output_store, session_id: @id) unless resolved.any? { |t| t.name == "output_read" }
864
+ end
775
865
  resolved
776
866
  end
777
867
 
@@ -870,7 +960,17 @@ module Ask
870
960
  }
871
961
  }
872
962
  @state.set(@id, payload)
873
- @checkpoint_store.checkpoint(@id, payload) if @checkpoints
963
+ # Checkpoint only when the conversation actually changed since the
964
+ # last one: the loop persists after every turn and run() persists
965
+ # again on the way out, so without this check every run would append
966
+ # a duplicate tail checkpoint.
967
+ if @checkpoints
968
+ head = @checkpoint_store.load(@id)
969
+ head_messages = head ? (head["messages"] || head[:messages] || []) : []
970
+ head_turn = head ? (head.dig("metadata", "turn_count") || head.dig(:metadata, :turn_count)) : nil
971
+ unchanged = head_messages.size == payload[:messages].size && head_turn == @turn_count
972
+ @checkpoint_store.checkpoint(@id, payload) unless unchanged
973
+ end
874
974
  end
875
975
 
876
976
  def try_auto_meta_agent
@@ -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.37.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.37.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: